From c4ea338035ae8745cc929184d295da91697ff62c Mon Sep 17 00:00:00 2001 From: Carson Gee Date: Wed, 16 Apr 2014 18:05:33 -0400 Subject: [PATCH 01/36] Additional logic to handle more course_image URL edge cases This changes logic to allow more missed use cases of course_image to work properly. The cases are: . XML courses with the course_image attribute set . Mongo courses that are imported without a contentstore . Mongo courses that have course_image set but don't have a content store It also exports default images_static_course.jpg to images/static_course.jpg to handle a use case where a course author uploaded an image to the default location in studio without using the studio interface for adding course images, they then export the course, and then import it without a contentstore --- .../xmodule/modulestore/tests/test_mongo.py | 50 +++++++++++++++++- .../xmodule/modulestore/xml_exporter.py | 22 ++++++++ .../simple/static/images_course_image.jpg | Bin 0 -> 547 bytes common/test/data/toy/course/2012_Fall.xml | 2 +- common/test/data/toy/static/just_a_test.jpg | Bin 0 -> 547 bytes lms/djangoapps/courseware/courses.py | 9 +++- .../courseware/tests/test_courses.py | 29 ++++++++-- 7 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 common/test/data/simple/static/images_course_image.jpg create mode 100644 common/test/data/toy/static/just_a_test.jpg diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 0663f9fdea..8ed44a1f5e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -1,11 +1,14 @@ from pprint import pprint # pylint: disable=E0611 from nose.tools import assert_equals, assert_raises, \ - assert_not_equals, assert_false + assert_not_equals, assert_false, assert_true from itertools import ifilter # pylint: enable=E0611 +from path import path import pymongo import logging +import shutil +from tempfile import mkdtemp from uuid import uuid4 from xblock.fields import Scope @@ -16,6 +19,7 @@ from xmodule.tests import DATA_DIR from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore from xmodule.modulestore.draft import DraftModuleStore +from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.contentstore.mongo import MongoContentStore @@ -310,6 +314,50 @@ class TestMongoModuleStore(object): assert_equals(len(course_locations), 1) assert_in(Location('i4x', 'edX', 'simple', 'course', '2012_Fall'), course_locations) + def test_export_course_image(self): + """ + Test to make sure that we have a course image in the contentstore, + then export it to ensure it gets copied to both file locations. + """ + location = Location('c4x', 'edX', 'simple', 'asset', 'images_course_image.jpg') + course_location = Location('i4x', 'edX', 'simple', 'course', '2012_Fall') + + # This will raise if the course image is missing + self.content_store.find(location) + + root_dir = path(mkdtemp()) + export_to_xml(self.store, self.content_store, course_location, root_dir, 'test_export') + assert_true(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) + assert_true(path(root_dir / 'test_export/static/images_course_image.jpg').isfile()) + shutil.rmtree(root_dir) + + def test_export_course_image_nondefault(self): + """ + Make sure that if a non-default image path is specified that we + don't export it to the static default location + """ + course = self.get_course_by_id('edX/toy/2012_Fall') + assert_true(course.course_image, 'just_a_test.jpg') + + root_dir = path(mkdtemp()) + export_to_xml(self.store, self.content_store, course.location, root_dir, 'test_export') + assert_true(path(root_dir / 'test_export/static/just_a_test.jpg').isfile()) + assert_false(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) + shutil.rmtree(root_dir) + + def test_course_without_image(self): + """ + Make sure we elegantly passover our code when there isn't a static + image + """ + course = self.get_course_by_id('edX/simple_with_draft/2012_Fall') + root_dir = path(mkdtemp()) + export_to_xml(self.store, self.content_store, course.location, root_dir, 'test_export') + assert_false(path(root_dir / 'test_export/static/images/course_image.jpg').isfile()) + assert_false(path(root_dir / 'test_export/static/images_course_image.jpg').isfile()) + shutil.rmtree(root_dir) + + class TestMongoKeyValueStore(object): """ diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index b442e2ef96..00c759385a 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -5,6 +5,8 @@ Methods for exporting course data to XML import logging import lxml.etree from xblock.fields import Scope +from xmodule.contentstore.content import StaticContent +from xmodule.exceptions import NotFoundError from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata from fs.osfs import OSFS @@ -79,6 +81,26 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d root_dir + '/' + course_dir + '/policies/assets.json', ) + # If we are using the default course image, export it to the + # legacy location to support backwards compatibility. + if course.course_image == course.fields['course_image'].default: + try: + course_image = contentstore.find( + StaticContent.compute_location( + course.location.org, + course.location.course, + course.course_image + ), + ) + except NotFoundError: + pass + else: + output_dir = root_dir + '/' + course_dir + '/static/images/' + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + with OSFS(output_dir).open('course_image.jpg', 'wb') as course_image_file: + course_image_file.write(course_image.data) + # export the static tabs export_extra_content(export_fs, modulestore, course_id, course_location, 'static_tab', 'tabs', '.html') diff --git a/common/test/data/simple/static/images_course_image.jpg b/common/test/data/simple/static/images_course_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6bb7f377a03e406c6a448c7adfc4994f03294874 GIT binary patch literal 547 zcmbu4y$ZrW5QJy$P%JF&z*6vhk`#f&2mxOqXh0hU30UUc#E0;w@kv}S1O>&IBD3A{ z?aqvx + diff --git a/common/test/data/toy/static/just_a_test.jpg b/common/test/data/toy/static/just_a_test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6bb7f377a03e406c6a448c7adfc4994f03294874 GIT binary patch literal 547 zcmbu4y$ZrW5QJy$P%JF&z*6vhk`#f&2mxOqXh0hU30UUc#E0;w@kv}S1O>&IBD3A{ z?aqvx Date: Mon, 5 May 2014 12:08:36 -0400 Subject: [PATCH 02/36] put google analytics in quotation marks in linkedin email template --- lms/templates/linkedin/linkedin_email.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/linkedin/linkedin_email.html b/lms/templates/linkedin/linkedin_email.html index bffbad7c4c..dbfaac5c5f 100644 --- a/lms/templates/linkedin/linkedin_email.html +++ b/lms/templates/linkedin/linkedin_email.html @@ -393,7 +393,7 @@ + (function() { + var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; + ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; + var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); + })(); + +% endif From 5890bc842c546ded4087159f3658e605b575960c Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Tue, 6 May 2014 10:47:59 -0400 Subject: [PATCH 04/36] removing larger size for CodeMirror for funky visual behavior on Checkbox problems in release --- cms/static/sass/elements/_modal-window.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index bf991bcecd..8df685f5f0 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -179,10 +179,6 @@ height: 365px; } - &.modal-type-problem .CodeMirror { - height: 435px; - } - .wrapper-comp-settings { .list-input { From b873cfc26d791767c9a16ab29e507b2192d0c11f Mon Sep 17 00:00:00 2001 From: Waheed Ahmed Date: Tue, 6 May 2014 20:35:38 +0500 Subject: [PATCH 05/36] Fixed simple editor text showing in advanced editor. --- cms/static/js/views/modals/edit_xblock.js | 8 +++----- common/lib/xmodule/xmodule/js/src/problem/edit.coffee | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 0e5e83fbef..f217d7c979 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -136,11 +136,9 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", mode = parent.data('mode'); event.preventDefault(); var $cheatsheet = $('.simple-editor-cheatsheet'); - if ($cheatsheet.hasClass("shown")) { - $(".CodeMirror").removeAttr("style"); - $(".modal-content").removeAttr("style"); - $cheatsheet.removeClass('shown'); - } + $(".CodeMirror").css({"overflow": "none"}); + $(".modal-content").removeAttr("style"); + $cheatsheet.removeClass('shown'); this.selectMode(mode); }, diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index ee318ecdb7..30d448dd65 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -109,7 +109,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor $(".CodeMirror").css({"overflow": "visible"}) $(".modal-content").css({"overflow-y": "visible", "overflow-x": "visible"}) else - $(".CodeMirror").removeAttr("style") + $(".CodeMirror").css({"overflow": "none"}) $(".modal-content").removeAttr("style") ### From 3c1b363c010e2b8392a19b0d6780539e49c271a2 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 6 May 2014 11:49:54 -0400 Subject: [PATCH 06/36] Remove (Grade Me!) template until it can be productized. --- .../xmodule/templates/html/grade_me.yaml | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 common/lib/xmodule/xmodule/templates/html/grade_me.yaml diff --git a/common/lib/xmodule/xmodule/templates/html/grade_me.yaml b/common/lib/xmodule/xmodule/templates/html/grade_me.yaml deleted file mode 100644 index ecb9a1fa4d..0000000000 --- a/common/lib/xmodule/xmodule/templates/html/grade_me.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -metadata: - display_name: (Grade Me!) Button -data: | -

By clicking the button below, you assert that you have completed the course in its entirety.

- - -

- - From 5613f19994265fdf6032bcc2d152ebe9c21310f2 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Tue, 6 May 2014 14:44:40 -0400 Subject: [PATCH 07/36] add quotation mark to _dashboard_course_listing.html --- lms/templates/dashboard/_dashboard_course_listing.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 229fed81d4..7fd90af1d0 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -52,7 +52,7 @@ ${_("Honor Code")} % elif enrollment.mode == "audit": - + ${_("Enrolled as: ")} ${_("Auditing")} From aa8ae810343d5f5ea0ecc2175f19d1a0e2ce0373 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 6 May 2014 17:52:43 -0400 Subject: [PATCH 08/36] Show same thing on dash for audit and honor code students. --- lms/templates/dashboard/_dashboard_course_listing.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 7fd90af1d0..4ed77505a3 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -82,7 +82,7 @@ - % if course.may_certify() and cert_status and not enrollment.mode == 'audit': + % if course.may_certify() and cert_status: <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/> % endif From 825ac1cd42d4b36328b688e6833fd13254d9e870 Mon Sep 17 00:00:00 2001 From: Joe Blaylock Date: Tue, 6 May 2014 15:12:36 -0700 Subject: [PATCH 09/36] Fix dashboard template around final grade status * ae8847cd6e73 checked in buggy conditional in the dashboard templates that caused an empty 'details being wrapped up' box to display regardless of whether it was true. * Simplify conditional so that we care only whether output of the view's _cert_info() says 'processing', which should be the value computed when either no cert status exists or when the certs are, in fact processing. This should mean that the "details being wrapped up" message is displayed when certs haven't run and the grade box is displayed the rest of the time. --- .../dashboard/_dashboard_certificate_information.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index d8b3977574..951091f371 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -24,11 +24,8 @@ else: %>
-% if cert_status['status'] == 'processing' and not course.may_certify(): +% if cert_status['status'] == 'processing':

${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

-% elif course.may_certify() and cert_status['status'] == 'processing': - - % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):

${_("Your final grade:")} ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. From 9fb2a8c753e82c8d55d6c66c258182bc341ebd90 Mon Sep 17 00:00:00 2001 From: Mark Hoeber Date: Mon, 5 May 2014 14:56:13 -0400 Subject: [PATCH 10/36] Release Notes for 5-6-14 Doc-331 --- .../en_us/release_notes/source/05-07-2014.rst | 74 +++++++++++++++++++ docs/en_us/release_notes/source/index.rst | 28 +++++-- docs/en_us/release_notes/source/links.rst | 17 ++++- docs/en_us/release_notes/source/read_me.rst | 13 +--- 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 docs/en_us/release_notes/source/05-07-2014.rst diff --git a/docs/en_us/release_notes/source/05-07-2014.rst b/docs/en_us/release_notes/source/05-07-2014.rst new file mode 100644 index 0000000000..94ca558c5f --- /dev/null +++ b/docs/en_us/release_notes/source/05-07-2014.rst @@ -0,0 +1,74 @@ +################################### +May 7, 2014 +################################### + +The following information reflects what is new in the edX Platform as of May 7, +2014. See previous pages in this document for a history of changes. + +************************** +edX Documentation +************************** + +You can access the `edX Status`_ page to get an up-to-date status for all +services on edx.org and edX Edge. The page also includes the Twitter feed for +@edXstatus, which the edX Operations team uses to post updates. + +You can access the public `edX roadmap`_ for +details about the currently planned product direction. + +The following documentation is available: + +* `Building and Running an edX Course`_ + + You can also download the guide as a PDF from the edX Studio user interface. + + Recent changes include: + + * Updated `Drag and Drop Problem`_ information. + + * Updated the `Discussions`_ chapter to include a topic on closing + discussions. + + * Expanded the `Grade and Answer Data`_ chapter to include a topic on + interpreting the score histograms for problems. + + * Added documentation for the `IFrame Tool`_ + +* `edX Data Documentation`_ + + Recent changes include: + + * Corrected misstatement on how `Discussion Forums Data`_ is sent in data + packages. + + * Added enrollment events to `Tracking Logs`_. + + * Removed obsolete information about XML formats for problems. + + +* `edX Platform Developer Documentation`_ + + +* `edX XBlock Documentation`_ + + + +************* +edX Studio +************* + +* In certain rare situations, users were not able to edit a draft unit without + making it Private. This problem is resolved. (STUD-1485, STUD-1499) + +* When you created problems with two inputs, the label for the second input was + not preserved in the Advanced Editor. This problem is resolved. (STUD-1524) + + +*************************************** +edX Learning Management System +*************************************** + +* Students registered for an honor code certificate now see their registration + track on the Dashboard. (LMS-1071) + +.. include:: links.rst \ No newline at end of file diff --git a/docs/en_us/release_notes/source/index.rst b/docs/en_us/release_notes/source/index.rst index 5acd02ec31..6a9f5af4af 100755 --- a/docs/en_us/release_notes/source/index.rst +++ b/docs/en_us/release_notes/source/index.rst @@ -3,14 +3,23 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +The edX *Release Notes for edX Course Staff* provides a cumulative +list of changes that affect course authoring in edX Studio and the edX Learning +Management System. -Contents -======== +There is a page in this document for each update to the edX system on `edx.org`_ and `edX Edge`_. Each page contains information on new or changed documentation, and new features and changes in edX Studio, the edX Learning Management System, Discussions, Analytics, accessibility, and more. + + + +######### +2014 +######### .. toctree:: - :maxdepth: 5 + :maxdepth: 1 read_me + 05-07-2014 04-29-2014 04-23-2014 04-16-2014 @@ -27,6 +36,14 @@ Contents 01-29-2014 01-16-2014 01-07-2014 + +######### +2013 +######### + +.. toctree:: + :maxdepth: 1 + 12-17-2013 12-09-2013 12-03-2013 @@ -35,7 +52,4 @@ Contents 10-23-2013 - - - - +.. include:: links.rst \ No newline at end of file diff --git a/docs/en_us/release_notes/source/links.rst b/docs/en_us/release_notes/source/links.rst index 316da414bb..67ceb17941 100644 --- a/docs/en_us/release_notes/source/links.rst +++ b/docs/en_us/release_notes/source/links.rst @@ -1,6 +1,15 @@ .. Links +.. _edX Edge: https://edge.edx.org + +.. _edx.org: http://edx.org + +.. _Sphinx: http://sphinx-doc.org/ +.. _LaTeX: http://www.latex-project.org/ +.. _GitHub Flow: https://github.com/blog/1557-github-flow-in-the-browser +.. _RST: http://docutils.sourceforge.net/rst.html + .. _edX Status: http://status.edx.org/ .. _edX roadmap: https://edx-wiki.atlassian.net/wiki/display/OPENPROD/Open+EdX+Public+Product+Roadmap @@ -137,6 +146,10 @@ .. _Problem with Adaptive Hint: http://edx.readthedocs.org/projects/ca/en/latest/exercises_tools/problem_with_hint.html +.. _IFrame Tool: http://edx.readthedocs.org/projects/ca/en/latest/exercises_tools/iframe.html + +.. _Drag and Drop Problem: http://ca.readthedocs.org/en/latest/exercises_tools/drag_and_drop.html + .. DATA DOCUMENTATION .. _Student Info and Progress Data: http://edx.readthedocs.org/projects/devdata/en/latest/internal_data_formats/sql_schema.html#student-info @@ -157,4 +170,6 @@ .. Developer Doc -.. _Contributing to Open edX: http://edx.readthedocs.org/projects/userdocs/en/latest/process/index.html \ No newline at end of file +.. _Contributing to Open edX: http://edx.readthedocs.org/projects/userdocs/en/latest/process/index.html + +.. _edX XBlock Documentation: http://edx.readthedocs.org/projects/xblock/en/latest/ \ No newline at end of file diff --git a/docs/en_us/release_notes/source/read_me.rst b/docs/en_us/release_notes/source/read_me.rst index c73d0554a9..d6ad5ee6c3 100644 --- a/docs/en_us/release_notes/source/read_me.rst +++ b/docs/en_us/release_notes/source/read_me.rst @@ -2,11 +2,8 @@ Read Me ******* -The edX *Release Notes for Course Staff* documentation provides a cumulative list of changes -that affect course authoring in edX Studio and the edX Learning Management System. - -This document is created using RST_ files and Sphinx_. You, the user community, can help update and revise -this documentation project on GitHub:: +This document is created using RST_ files and Sphinx_. You, the user community, +can help update and revise this documentation project on GitHub:: https://github.com/edx/edx-platform/tree/master/docs/en_us/release_notes/source @@ -14,7 +11,5 @@ To suggest a revision, fork the project, make changes in your fork, and submit a pull request back to the original project: this is known as the `GitHub Flow`_. All pull requests need approval from edX. For more information, contact edX at docs@edx.org. -.. _Sphinx: http://sphinx-doc.org/ -.. _LaTeX: http://www.latex-project.org/ -.. _`GitHub Flow`: https://github.com/blog/1557-github-flow-in-the-browser -.. _RST: http://docutils.sourceforge.net/rst.html \ No newline at end of file + +.. include:: links.rst \ No newline at end of file From 4778eff817175df954c82e01f2b402a5808a934e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 7 May 2014 14:51:27 -0400 Subject: [PATCH 11/36] Disable a flaky ORA Javascript test. --- .../xmodule/js/spec/combinedopenended/display_spec.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee index ef2c3cf0f9..f90728728a 100644 --- a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee @@ -81,7 +81,7 @@ describe 'CombinedOpenEnded', -> expect(window.setTimeout).toHaveBeenCalledWith(@combined.poll, 10000) expect(window.queuePollerID).toBe(5) - it 'polling stops properly', => + xit 'polling stops properly', => fakeResponseDone = state: "done" spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone) @combined.poll() From 18edab35f3e71af7d009ecd9be625a4285cb89c4 Mon Sep 17 00:00:00 2001 From: polesye Date: Thu, 8 May 2014 14:47:20 +0300 Subject: [PATCH 12/36] BLD-1057: Fix Video player in FF. --- .../xmodule/js/spec/video/general_spec.js | 53 ------ .../js/spec/video/video_control_spec.js | 7 +- .../js/spec/video/video_player_spec.js | 26 +-- .../xmodule/js/src/video/03_video_player.js | 155 +++++++----------- .../xmodule/xmodule/js/src/video/10_main.js | 21 ++- 5 files changed, 88 insertions(+), 174 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 892a0e33d1..fed16ddfa2 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -177,59 +177,6 @@ }); }); - describe('YouTube video in FireFox will cue first', function () { - var oldUserAgent; - - beforeEach(function () { - oldUserAgent = window.navigator.userAgent; - window.navigator.userAgent = 'firefox'; - - state = jasmine.initializePlayer('video.html', { - start: 10, - end: 30 - }); - }); - - afterEach(function () { - window.navigator.userAgent = oldUserAgent; - }); - - it('cue is called, skipOnEndedStartEndReset is set', function () { - state.videoPlayer.updatePlayTime(10); - expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 10); - expect(state.videoPlayer.skipOnEndedStartEndReset).toBe(true); - }); - - it('when position is not 0: cue is called with stored position value', function () { - state.config.savedVideoPosition = 15; - - state.videoPlayer.updatePlayTime(10); - expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 15); - }); - - it('Handling cue state', function () { - spyOn(state.videoPlayer, 'play'); - - state.videoPlayer.seekToTimeOnCued = 10; - state.videoPlayer.onStateChange({data: 5}); - - expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true); - expect(state.videoPlayer.play).toHaveBeenCalled(); - }); - - it('even when cued, onEnded does not resets start and end time', function () { - state.videoPlayer.skipOnEndedStartEndReset = true; - state.videoPlayer.onEnded(); - expect(state.videoPlayer.startTime).toBe(10); - expect(state.videoPlayer.endTime).toBe(30); - - state.videoPlayer.skipOnEndedStartEndReset = undefined; - state.videoPlayer.onEnded(); - expect(state.videoPlayer.startTime).toBe(10); - expect(state.videoPlayer.endTime).toBe(30); - }); - }); - describe('checking start and end times', function () { var miniTestSuite = [ { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js index e78ef752f4..21cd0587ef 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_control_spec.js @@ -13,6 +13,7 @@ afterEach(function () { $('source').remove(); state.storage.clear(); + window.Video.previousState = null; window.onTouchBasedDevice = oldOTBD; }); @@ -37,7 +38,7 @@ }); it('add ARIA attributes to time control', function () { - var timeControl = $('div.slider>a'); + var timeControl = $('div.slider > a'); expect(timeControl).toHaveAttrs({ 'role': 'slider', @@ -135,8 +136,6 @@ expectedValue = sliderEl.slider('option', 'value'); expect(expectedValue).toBe(10); - - state.storage.clear(); }); }); @@ -389,7 +388,7 @@ runs(function () { state = jasmine.initializePlayer({ end: 20, - savedVideoPosition: 'a' + savedVideoPosition: 'a' }); sliderEl = state.videoProgressSlider.slider; spyOn(state.videoPlayer, 'duration').andReturn(60); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index f404a47d0a..3b7cd29f49 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -17,6 +17,7 @@ function (VideoPlayer) { afterEach(function () { $('source').remove(); window.onTouchBasedDevice = oldOTBD; + window.Video.previousState = null; if (state.storage) { state.storage.clear(); } @@ -179,6 +180,11 @@ function (VideoPlayer) { it('autoplay the first video', function () { expect(state.videoPlayer.play).not.toHaveBeenCalled(); }); + + + it('invalid endTime is reset to null', function () { + expect(state.videoPlayer.endTime).toBe(null); + }); }); describe('onReady YouTube', function () { @@ -752,17 +758,6 @@ function (VideoPlayer) { isFlashMode: jasmine.createSpy().andReturn(false) }; }); - - it('invalid endTime is reset to null', function () { - VideoPlayer.prototype.updatePlayTime.call(state, 0); - - expect(state.videoPlayer.figureOutStartingTime).toHaveBeenCalled(); - - VideoPlayer.prototype.figureOutStartEndTime.call(state, 60); - VideoPlayer.prototype.figureOutStartingTime.call(state, 60); - - expect(state.videoPlayer.endTime).toBe(null); - }); }); describe('toggleFullScreen', function () { @@ -1087,9 +1082,12 @@ function (VideoPlayer) { isHtml5Mode: jasmine.createSpy().andReturn(true), isYoutubeType: jasmine.createSpy().andReturn(true), setPlayerMode: jasmine.createSpy(), + trigger: jasmine.createSpy(), videoPlayer: { currentTime: 60, isPlaying: jasmine.createSpy(), + seekTo: jasmine.createSpy(), + duration: jasmine.createSpy().andReturn(60), updatePlayTime: jasmine.createSpy(), setPlaybackRate: jasmine.createSpy(), player: jasmine.createSpyObj('player', [ @@ -1115,6 +1113,12 @@ function (VideoPlayer) { state.videoPlayer.isPlaying.andReturn(false); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.seekTo).toHaveBeenCalledWith(60); + expect(state.trigger).toHaveBeenCalledWith( + 'videoProgressSlider.updateStartEndTimeRegion', + { + duration: 60 + }); expect(state.videoPlayer.player.cueVideoById) .toHaveBeenCalledWith('videoId', 60); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index cda33180c3..07dd005366 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -44,6 +44,7 @@ function (HTML5Video, Resizer) { onVolumeChange: onVolumeChange, pause: pause, play: play, + seekTo: seekTo, setPlaybackRate: setPlaybackRate, update: update, figureOutStartEndTime: figureOutStartEndTime, @@ -94,7 +95,7 @@ function (HTML5Video, Resizer) { state.videoPlayer.ready = _.once(function () { $(window).on('unload', state.saveState); - if (!state.isFlashMode()) { + if (!state.isFlashMode() && state.speed != '1.0') { state.videoPlayer.setPlaybackRate(state.speed); } state.videoPlayer.player.setVolume(state.currentVolume); @@ -352,7 +353,8 @@ function (HTML5Video, Resizer) { } function setPlaybackRate(newSpeed) { - var time = this.videoPlayer.currentTime, + var duration = this.videoPlayer.duration(), + time = this.videoPlayer.currentTime, methodName, youtubeId; if ( @@ -378,7 +380,22 @@ function (HTML5Video, Resizer) { } this.videoPlayer.player[methodName](youtubeId, time); + + // We need to call play() explicitly because after the call + // to functions cueVideoById() followed by seekTo() the video + // is in a PAUSED state. + // + // Why? This is how the YouTube API is implemented. this.videoPlayer.updatePlayTime(time); + if (time > 0 && this.isFlashMode()) { + this.videoPlayer.seekTo(time); + this.trigger( + 'videoProgressSlider.updateStartEndTimeRegion', + { + duration: duration + } + ); + } } } @@ -414,59 +431,62 @@ function (HTML5Video, Resizer) { // It is created on a onPlay event. Cleared on a onPause event. // Reinitialized on a onSeek event. function onSeek(params) { - var duration = this.videoPlayer.duration(), - newTime = params.time; - - if ( - (typeof newTime !== 'number') || - (newTime > duration) || - (newTime < 0) - ) { - return; - } - - this.el.off('play.seek'); - this.videoPlayer.log( - 'seek_video', - { - old_time: this.videoPlayer.currentTime, - new_time: newTime, - type: params.type - } - ); + var time = params.time, + type = params.type; // After the user seeks, the video will start playing from // the sought point, and stop playing at the end. this.videoPlayer.goToStartTime = false; - if (newTime > this.videoPlayer.endTime || this.videoPlayer.endTime === null) { + if (time > this.videoPlayer.endTime || this.videoPlayer.endTime === null) { this.videoPlayer.stopAtEndTime = false; } + this.videoPlayer.seekTo(time); + + this.videoPlayer.log( + 'seek_video', + { + old_time: this.videoPlayer.currentTime, + new_time: time, + type: type + } + ); + } + + function seekTo(time) { + var duration = this.videoPlayer.duration(); + + if ((typeof time !== 'number') || (time > duration) || (time < 0)) { + return false; + } + + this.el.off('play.seek'); + if (this.videoPlayer.isPlaying()) { this.videoPlayer.stopTimer(); } else { - this.videoPlayer.currentTime = newTime; + this.videoPlayer.currentTime = time; } var isUnplayed = this.videoPlayer.isUnstarted() || this.videoPlayer.isCued(); // Use `cueVideoById` method for youtube video that is not played before. if (isUnplayed && this.isYoutubeType()) { - this.videoPlayer.player.cueVideoById(this.youtubeId(), newTime); + this.videoPlayer.player.cueVideoById(this.youtubeId(), time); } else { // Youtube video cannot be rewinded during bufferization, so wait to // finish bufferization and then rewind the video. if (this.isYoutubeType() && this.videoPlayer.isBuffering()) { this.el.on('play.seek', function () { - this.videoPlayer.player.seekTo(newTime, true); + this.videoPlayer.player.seekTo(time, true); }.bind(this)); } else { // Otherwise, just seek the video - this.videoPlayer.player.seekTo(newTime, true); + this.videoPlayer.player.seekTo(time, true); } } - this.videoPlayer.updatePlayTime(newTime, true); + this.videoPlayer.updatePlayTime(time, true); this.el.trigger('seek', arguments); } @@ -609,6 +629,7 @@ function (HTML5Video, Resizer) { // have 1 speed available, we fall back to Flash. _restartUsingFlash(this); + return false; } else if (availablePlaybackRates.length > 1) { this.setPlayerMode('html5'); @@ -646,16 +667,15 @@ function (HTML5Video, Resizer) { this.videoPlayer.player.setPlaybackRate(this.speed); } - this.el.trigger('ready', arguments); - /* The following has been commented out to make sure autoplay is - disabled for students. - if ( - !this.isTouch && - $('.video:first').data('autoplay') === 'True' - ) { - this.videoPlayer.play(); + + var duration = this.videoPlayer.duration(), + time = this.videoPlayer.figureOutStartingTime(duration); + + if (time > 0 && this.videoPlayer.goToStartTime) { + this.videoPlayer.seekTo(time); } - */ + + this.el.trigger('ready', arguments); } function onStateChange(event) { @@ -687,13 +707,9 @@ function (HTML5Video, Resizer) { break; case this.videoPlayer.PlayerState.CUED: this.el.addClass('is-cued'); - this.videoPlayer.player.seekTo(this.videoPlayer.seekToTimeOnCued, true); - // We need to call play() explicitly because after the call - // to functions cueVideoById() followed by seekTo() the video - // is in a PAUSED state. - // - // Why? This is how the YouTube API is implemented. - this.videoPlayer.play(); + if (this.isFlashMode()) { + this.videoPlayer.play(); + } break; } } @@ -769,57 +785,6 @@ function (HTML5Video, Resizer) { duration = this.videoPlayer.duration(), youTubeId; - if (duration > 0 && videoPlayer.goToStartTime && !skip_seek) { - videoPlayer.goToStartTime = false; - - // The duration might have changed. Update the start-end time region to - // reflect this fact. - this.trigger( - 'videoProgressSlider.updateStartEndTimeRegion', - { - duration: duration - } - ); - - time = videoPlayer.figureOutStartingTime(duration); - - // When the video finishes playing, we will start from the - // start-time, or from the beginning (rather than from the remembered - // position). - this.config.savedVideoPosition = 0; - - if (time > 0) { - // After a bug came up (BLD-708: "In Firefox YouTube video with - // start-time plays from 00:00:00") the video refused to play - // from start-time, and only played from the beginning. - // - // It turned out that for some reason if Firefox you couldn't - // seek beyond some amount of time before the video loaded. - // Very strange, but in Chrome there is no such bug. - // - // HTML5 video sources play fine from start-time in both Chrome - // and Firefox. - if (this.browserIsFirefox && this.isYoutubeType()) { - youTubeId = this.youtubeId(); - - // When we will call cueVideoById() for some strange reason - // an ENDED event will be fired. It really does no damage - // except for the fact that the end-time is reset to null. - // We do not want this. - // - // The flag `skipOnEndedStartEndReset` will notify the - // onEnded() callback for the ENDED event that there - // is no need in resetting the start-time and end-time. - videoPlayer.skipOnEndedStartEndReset = true; - - videoPlayer.seekToTimeOnCued = time; - videoPlayer.player.cueVideoById(youTubeId, time); - } else { - videoPlayer.player.seekTo(time); - } - } - } - this.trigger( 'videoProgressSlider.updatePlayTime', { diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js index 038b5f3577..5e8d9f2811 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -57,18 +57,11 @@ VideoCaption ) { var youtubeXhr = null, - oldVideo = window.Video, - - // Because this constructor can be called multiple times on a single page (when the user switches - // verticals, the page doesn't reload, but the content changes), we must will check each time if there - // is a previous copy of 'state' object. If there is, we will make sure that copy exists cleanly. We - // have to do this because when verticals switch, the code does not handle any Xmodule JS code that is - // running - it simply removes DOM elements from the page. Any functions that were running during this, - // and that will run afterwards (expecting the DOM elements to be present) must be stopped by hand. - previousState = null; + oldVideo = window.Video; window.Video = function (element) { - var state; + var previousState = window.Video.previousState, + state; // Check for existance of previous state, uninitialize it if necessary, and create a new state. Store // new state for future invocation of this module consturctor function. @@ -78,7 +71,13 @@ } state = {}; - previousState = state; + // Because this constructor can be called multiple times on a single page (when the user switches + // verticals, the page doesn't reload, but the content changes), we must will check each time if there + // is a previous copy of 'state' object. If there is, we will make sure that copy exists cleanly. We + // have to do this because when verticals switch, the code does not handle any Xmodule JS code that is + // running - it simply removes DOM elements from the page. Any functions that were running during this, + // and that will run afterwards (expecting the DOM elements to be present) must be stopped by hand. + window.Video.previousState = state; state.modules = [ FocusGrabber, From c13282462a9fa458a58a3d14102dbb0f0c7ab73c Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 28 Apr 2014 12:20:47 +0300 Subject: [PATCH 13/36] Modify paver run_all_servers to allow different settings for LMS and Studio. --- docs/en_us/developers/source/pavelib.rst | 7 ++++++- pavelib/servers.py | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/en_us/developers/source/pavelib.rst b/docs/en_us/developers/source/pavelib.rst index 5d17610bbd..bdd52907a5 100644 --- a/docs/en_us/developers/source/pavelib.rst +++ b/docs/en_us/developers/source/pavelib.rst @@ -140,7 +140,12 @@ Run Servers **run_celery**: runs celery for specified system - *--settings=* Environment settings e.g. aws, dev + *--settings=* Environment settings e.g. aws, dev both for LMS and Studio + + *--settings_lms=* Override django settings for LMS e.g. cms.dev + + *--settings_cms=* Override django settings for Studio + :: diff --git a/pavelib/servers.py b/pavelib/servers.py index 6c7f1890a3..9f2c176cba 100644 --- a/pavelib/servers.py +++ b/pavelib/servers.py @@ -105,26 +105,33 @@ def celery(options): @task @needs('pavelib.prereqs.install_prereqs') @cmdopts([ - ("settings=", "s", "Django settings"), + ("settings=", "s", "Django settings for both LMS and Studio"), ("worker_settings=", "w", "Celery worker Django settings"), - ("fast", "f", "Skip updating assets") + ("fast", "f", "Skip updating assets"), + ("settings_lms=", "l", "Set LMS only, overriding the value from --settings (if provided)"), + ("settings_cms=", "c", "Set Studio only, overriding the value from --settings (if provided)"), ]) def run_all_servers(options): """ Runs Celery workers, Studio, and LMS. """ settings = getattr(options, 'settings', 'dev') + settings_lms = getattr(options, 'settings_lms', settings) + settings_cms = getattr(options, 'settings_cms', settings) worker_settings = getattr(options, 'worker_settings', 'dev_with_worker') fast = getattr(options, 'fast', False) if not fast: - for system in ['lms', 'studio']: - args = [system, '--settings={}'.format(settings), '--skip-collect'] - call_task('pavelib.assets.update_assets', args=args) + args = ['lms', '--settings={}'.format(settings_lms), '--skip-collect'] + call_task('pavelib.assets.update_assets', args=args) + + args = ['studio', '--settings={}'.format(settings_cms), '--skip-collect'] + call_task('pavelib.assets.update_assets', args=args) + call_task('pavelib.assets.watch_assets', options={'background': True}) run_multi_processes([ - django_cmd('lms', settings, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['lms'])), - django_cmd('studio', settings, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['studio'])), + django_cmd('lms', settings_lms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['lms'])), + django_cmd('studio', settings_cms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['studio'])), django_cmd('lms', worker_settings, 'celery', 'worker', '--loglevel=INFO', '--pythonpath=.') ]) From ced09a6b04efb05c52b453b05b16c58ba23d000d Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Fri, 7 Mar 2014 14:13:12 -0500 Subject: [PATCH 14/36] Switch default instructor dashboard to new dash LMS-1296 --- CHANGELOG.rst | 4 ++ .../instructor/views/instructor_dashboard.py | 2 +- lms/djangoapps/instructor/views/legacy.py | 6 +- lms/envs/common.py | 4 +- lms/envs/dev.py | 2 +- lms/envs/test.py | 2 +- .../courseware/instructor_dashboard.html | 12 ++++ .../instructor_dashboard_2.html | 61 ++++++++++--------- lms/urls.py | 26 ++++---- 9 files changed, 69 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 79de8c09f4..6f004b9657 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ Studio: Add drag-and-drop support to the container page. STUD-1309. Common: Add extensible third-party auth module. +LMS: Switch default instructor dashboard to the new (formerly "beta") + instructor dashboard. Puts the old (now "legacy") dash behind a feature flag. + LMS-1296 + Blades: Handle situation if no response were sent from XQueue to LMS in Matlab problem after Run Code button press. BLD-994. diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index ed9520420b..774bc2cf19 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -79,7 +79,7 @@ def instructor_dashboard_2(request, course_id): context = { 'course': course, - 'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}), + 'old_dashboard_url': reverse('instructor_dashboard_2', kwargs={'course_id': course_id}), 'studio_url': studio_url, 'sections': sections, 'disable_buttons': disable_buttons, diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 7f62501a12..45ecc746ca 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -1,6 +1,9 @@ """ Instructor Views """ +## NOTE: This is the code for the legacy instructor dashboard +## We are no longer supporting this file or accepting changes into it. + from contextlib import contextmanager import csv import json @@ -946,8 +949,7 @@ def instructor_dashboard(request, course_id): 'metrics_results': metrics_results, } - if settings.FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): - context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id}) + context['beta_dashboard_url'] = reverse('instructor_dashboard', kwargs={'course_id': course_id}) return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/envs/common.py b/lms/envs/common.py index 884e5d874b..4b4099f440 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -169,8 +169,8 @@ FEATURES = { # Enable instructor to assign individual due dates 'INDIVIDUAL_DUE_DATES': False, - # Enable instructor dash beta version link - 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': True, + # Enable legacy instructor dashboard + 'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True, # Toggle to enable certificates of courses on dashboard 'ENABLE_VERIFIED_CERTIFICATES': False, diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 4a2b9517e0..c5f07693f9 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -31,7 +31,7 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms) FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True -FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = True FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True FEATURES['ENABLE_SHOPPING_CART'] = True FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True diff --git a/lms/envs/test.py b/lms/envs/test.py index edaa4c7c45..110e7a48f6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -33,7 +33,7 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True -FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = True FEATURES['ENABLE_SHOPPING_CART'] = True diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index f80a94644a..cf549338f3 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -1,3 +1,6 @@ +## NOTE: This is the template for the legacy instructor dashboard +## We are no longer supporting this file or accepting changes into it. + <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> @@ -117,6 +120,13 @@ function goto( mode)

+ + + %if studio_url: + ## not checking access because if user can see this, they are at least course staff (with studio edit access) + + %endif +
%if studio_url: @@ -129,6 +139,8 @@ function goto( mode)

${_("Instructor Dashboard")}

+ # TODO put in a banner +