From 27646b7e6fa374f1b48b29896fb2a24ef0d9b4e7 Mon Sep 17 00:00:00 2001 From: Mushtaq Ali Date: Mon, 25 Apr 2016 17:05:45 +0500 Subject: [PATCH] Basic architecture of Maintenance App - SUST-19, SUST-42. Implement Force Publish Course view. SUST-46 --- .../views/tests/test_certificates.py | 10 +- .../views/tests/test_header_menu.py | 4 +- cms/djangoapps/maintenance/__init__.py | 0 cms/djangoapps/maintenance/tests.py | 248 ++++++++++++++++++ cms/djangoapps/maintenance/urls.py | 13 + cms/djangoapps/maintenance/views.py | 213 +++++++++++++++ cms/envs/common.py | 3 + .../js/maintenance/force_publish_course.js | 82 ++++++ cms/static/sass/_build-v1.scss | 1 + cms/static/sass/views/_maintenance.scss | 71 +++++ ...force-published-course-response.underscore | 14 + .../maintenance/_force_publish_course.html | 33 +++ cms/templates/maintenance/base.html | 21 ++ cms/templates/maintenance/container.html | 28 ++ cms/templates/maintenance/index.html | 20 ++ cms/templates/widgets/user_dropdown.html | 6 + cms/urls.py | 6 + common/djangoapps/util/views.py | 20 +- lms/djangoapps/instructor/views/api.py | 15 +- 19 files changed, 789 insertions(+), 19 deletions(-) create mode 100644 cms/djangoapps/maintenance/__init__.py create mode 100644 cms/djangoapps/maintenance/tests.py create mode 100644 cms/djangoapps/maintenance/urls.py create mode 100644 cms/djangoapps/maintenance/views.py create mode 100644 cms/static/js/maintenance/force_publish_course.js create mode 100644 cms/static/sass/views/_maintenance.scss create mode 100644 cms/templates/js/maintenance/force-published-course-response.underscore create mode 100644 cms/templates/maintenance/_force_publish_course.html create mode 100644 cms/templates/maintenance/base.html create mode 100644 cms/templates/maintenance/container.html create mode 100644 cms/templates/maintenance/index.html diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index d6d52cbba1..e2a1f096c4 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -26,7 +26,7 @@ from course_modes.tests.factories import CourseModeFactory from contentstore.views.certificates import CertificateManager from django.test.utils import override_settings from contentstore.utils import get_lms_link_for_certificate_web_view -from util.testing import EventTestMixin +from util.testing import EventTestMixin, UrlResetMixin FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @@ -197,7 +197,8 @@ class CertificatesBaseTestCase(object): @ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) -class CertificatesListHandlerTestCase(EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods): +class CertificatesListHandlerTestCase( + EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods, UrlResetMixin): """ Test cases for certificates_list_handler. """ @@ -206,6 +207,7 @@ class CertificatesListHandlerTestCase(EventTestMixin, CourseTestCase, Certificat Set up CertificatesListHandlerTestCase. """ super(CertificatesListHandlerTestCase, self).setUp('contentstore.views.certificates.tracker') + self.reset_urls() def _url(self): """ @@ -420,7 +422,8 @@ class CertificatesListHandlerTestCase(EventTestMixin, CourseTestCase, Certificat @ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) -class CertificatesDetailHandlerTestCase(EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods): +class CertificatesDetailHandlerTestCase( + EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods, UrlResetMixin): """ Test cases for CertificatesDetailHandlerTestCase. """ @@ -432,6 +435,7 @@ class CertificatesDetailHandlerTestCase(EventTestMixin, CourseTestCase, Certific Set up CertificatesDetailHandlerTestCase. """ super(CertificatesDetailHandlerTestCase, self).setUp('contentstore.views.certificates.tracker') + self.reset_urls() def _url(self, cid=-1): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_header_menu.py b/cms/djangoapps/contentstore/views/tests/test_header_menu.py index c27048452a..a854e33d6e 100644 --- a/cms/djangoapps/contentstore/views/tests/test_header_menu.py +++ b/cms/djangoapps/contentstore/views/tests/test_header_menu.py @@ -8,13 +8,14 @@ from django.test.utils import override_settings from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url +from util.testing import UrlResetMixin FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) -class TestHeaderMenu(CourseTestCase): +class TestHeaderMenu(CourseTestCase, UrlResetMixin): """ Unit tests for the course header menu. """ @@ -23,6 +24,7 @@ class TestHeaderMenu(CourseTestCase): Set up the for the course header menu tests. """ super(TestHeaderMenu, self).setUp() + self.reset_urls() def test_header_menu_without_web_certs_enabled(self): """ diff --git a/cms/djangoapps/maintenance/__init__.py b/cms/djangoapps/maintenance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py new file mode 100644 index 0000000000..696c703020 --- /dev/null +++ b/cms/djangoapps/maintenance/tests.py @@ -0,0 +1,248 @@ +""" +Tests for the maintenance app views. +""" +import ddt +import json + +from django.conf import settings +from django.core.urlresolvers import reverse + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +from contentstore.management.commands.utils import get_course_versions +from student.tests.factories import AdminFactory, UserFactory + +from .views import COURSE_KEY_ERROR_MESSAGES, MAINTENANCE_VIEWS + + +# This list contains URLs of all maintenance app views. +MAINTENANCE_URLS = [reverse(view['url']) for view in MAINTENANCE_VIEWS.values()] + + +class TestMaintenanceIndex(ModuleStoreTestCase): + """ + Tests for maintenance index view. + """ + + def setUp(self): + super(TestMaintenanceIndex, self).setUp() + self.user = AdminFactory() + login_success = self.client.login(username=self.user.username, password='test') + self.assertTrue(login_success) + self.view_url = reverse('maintenance:maintenance_index') + + def test_maintenance_index(self): + """ + Test that maintenance index view lists all the maintenance app views. + """ + response = self.client.get(self.view_url) + self.assertContains(response, 'Maintenance', status_code=200) + + # Check that all the expected links appear on the index page. + for url in MAINTENANCE_URLS: + self.assertContains(response, url, status_code=200) + + +@ddt.ddt +class MaintenanceViewTestCase(ModuleStoreTestCase): + """ + Base class for maintenance view tests. + """ + view_url = '' + + def setUp(self): + super(MaintenanceViewTestCase, self).setUp() + self.user = AdminFactory() + login_success = self.client.login(username=self.user.username, password='test') + self.assertTrue(login_success) + + def verify_error_message(self, data, error_message): + """ + Verify the response contains error message. + """ + response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertContains(response, error_message, status_code=200) + + def tearDown(self): + """ + Reverse the setup. + """ + self.client.logout() + super(MaintenanceViewTestCase, self).tearDown() + + +@ddt.ddt +class MaintenanceViewAccessTests(MaintenanceViewTestCase): + """ + Tests for access control of maintenance views. + """ + @ddt.data(MAINTENANCE_URLS) + @ddt.unpack + def test_require_login(self, url): + """ + Test that maintenance app requires user login. + """ + # Log out then try to retrieve the page + self.client.logout() + response = self.client.get(url) + + # Expect a redirect to the login page + redirect_url = '{login_url}?next={original_url}'.format( + login_url=reverse('login'), + original_url=url, + ) + + self.assertRedirects(response, redirect_url) + + @ddt.data(MAINTENANCE_URLS) + @ddt.unpack + def test_global_staff_access(self, url): + """ + Test that all maintenance app views are accessible to global staff user. + """ + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + @ddt.data(MAINTENANCE_URLS) + @ddt.unpack + def test_non_global_staff_access(self, url): + """ + Test that all maintenance app views are not accessible to non-global-staff user. + """ + user = UserFactory(username='test', email='test@example.com', password='test') + login_success = self.client.login(username=user.username, password='test') + self.assertTrue(login_success) + + response = self.client.get(url) + self.assertContains( + response, + 'Must be {platform_name} staff to perform this action.'.format(platform_name=settings.PLATFORM_NAME), + status_code=403 + ) + + +@ddt.ddt +class TestForcePublish(MaintenanceViewTestCase): + """ + Tests for the force publish view. + """ + + def setUp(self): + super(TestForcePublish, self).setUp() + self.view_url = reverse('maintenance:force_publish_course') + + def setup_test_course(self): + """ + Creates the course and add some changes to it. + + Returns: + course: a course object + """ + course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) + # Add some changes to course + chapter = ItemFactory.create(category='chapter', parent_location=course.location) + self.store.create_child( + self.user.id, # pylint: disable=no-member + chapter.location, + 'html', + block_id='html_component' + ) + # verify that course has changes. + self.assertTrue(self.store.has_changes(self.store.get_item(course.location))) + return course + + @ddt.data( + ('', COURSE_KEY_ERROR_MESSAGES['empty_course_key']), + ('edx', COURSE_KEY_ERROR_MESSAGES['invalid_course_key']), + ('course-v1:e+d+X', COURSE_KEY_ERROR_MESSAGES['course_key_not_found']), + ) + @ddt.unpack + def test_invalid_course_key_messages(self, course_key, error_message): + """ + Test all error messages for invalid course keys. + """ + # validate that course key contains error message + self.verify_error_message( + data={'course-id': course_key}, + error_message=error_message + ) + + def test_mongo_course(self): + """ + Test that we get a error message on old mongo courses. + """ + # validate non split error message + course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo) + self.verify_error_message( + data={'course-id': unicode(course.id)}, + error_message='Force publishing course is not supported with old mongo courses.' + ) + + def test_already_published(self): + """ + Test that when a course is forcefully publish, we get a 'course is already published' message. + """ + course = self.setup_test_course() + + # publish the course + source_store = modulestore()._get_modulestore_for_courselike(course.id) # pylint: disable=protected-access + source_store.force_publish_course(course.id, self.user.id, commit=True) # pylint: disable=no-member + + # now course is published, we should get `already published course` error. + self.verify_error_message( + data={'course-id': unicode(course.id)}, + error_message='Course is already in published state.' + ) + + def verify_versions_are_different(self, course): + """ + Verify draft and published versions point to different locations. + + Arguments: + course (object): a course object. + """ + # get draft and publish branch versions + versions = get_course_versions(unicode(course.id)) + + # verify that draft and publish point to different versions + self.assertNotEqual(versions['draft-branch'], versions['published-branch']) + + def get_force_publish_course_response(self, course): + """ + Get force publish the course response. + + Arguments: + course (object): a course object. + + Returns: + response : response from force publish post view. + """ + # Verify versions point to different locations initially + self.verify_versions_are_different(course) + + # force publish course view + data = { + 'course-id': unicode(course.id) + } + response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response_data = json.loads(response.content) + return response_data + + def test_force_publish_dry_run(self): + """ + Test that dry run does not publishes the course but shows possible outcome if force published is executed. + """ + course = self.setup_test_course() + response = self.get_force_publish_course_response(course) + + self.assertIn('current_versions', response) + + # verify that course still has changes as we just dry ran force publish course. + self.assertTrue(self.store.has_changes(self.store.get_item(course.location))) + + # verify that both branch versions are still different + self.verify_versions_are_different(course) diff --git a/cms/djangoapps/maintenance/urls.py b/cms/djangoapps/maintenance/urls.py new file mode 100644 index 0000000000..99ee2bc40d --- /dev/null +++ b/cms/djangoapps/maintenance/urls.py @@ -0,0 +1,13 @@ +""" +URLs for the maintenance app. +""" +from django.conf.urls import patterns, url + +from .views import MaintenanceIndexView, ForcePublishCourseView + + +urlpatterns = patterns( + '', + url(r'^$', MaintenanceIndexView.as_view(), name='maintenance_index'), + url(r'^force_publish_course/?$', ForcePublishCourseView.as_view(), name='force_publish_course'), +) diff --git a/cms/djangoapps/maintenance/views.py b/cms/djangoapps/maintenance/views.py new file mode 100644 index 0000000000..b370d4bc5c --- /dev/null +++ b/cms/djangoapps/maintenance/views.py @@ -0,0 +1,213 @@ +""" +Views for the maintenance app. +""" +import logging +from django.db import transaction +from django.core.validators import ValidationError +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext as _ +from django.views.generic import View + +from edxmako.shortcuts import render_to_response +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +from contentstore.management.commands.utils import get_course_versions +from util.json_request import JsonResponse +from util.views import require_global_staff + + +log = logging.getLogger(__name__) + +# This dict maintains all the views that will be used Maintenance app. +MAINTENANCE_VIEWS = { + 'force_publish_course': { + 'url': 'maintenance:force_publish_course', + 'name': _('Force Publish Course'), + 'slug': 'force_publish_course', + 'description': _( + 'Sometimes the draft and published branches of a course can get out of sync. Force publish course command ' + 'resets the published branch of a course to point to the draft branch, effectively force publishing the ' + 'course. This view dry runs the force publish command' + ), + }, +} + + +COURSE_KEY_ERROR_MESSAGES = { + 'empty_course_key': _('Please provide course id.'), + 'invalid_course_key': _('Invalid course key.'), + 'course_key_not_found': _('No matching course found.') +} + + +class MaintenanceIndexView(View): + """ + Index view for maintenance dashboard, used by global staff. + + This view lists some commands/tasks that can be used to dry run or execute directly. + """ + + @method_decorator(require_global_staff) + def get(self, request): + """Render the maintenance index view. """ + return render_to_response('maintenance/index.html', { + 'views': MAINTENANCE_VIEWS, + }) + + +class MaintenanceBaseView(View): + """ + Base class for Maintenance views. + """ + + template = 'maintenance/container.html' + + def __init__(self, view=None): + self.context = { + 'view': view if view else '', + 'form_data': {}, + 'error': False, + 'msg': '' + } + + def render_response(self): + """ + A short method to render_to_response that renders response. + """ + if self.request.is_ajax(): + return JsonResponse(self.context) + return render_to_response(self.template, self.context) + + @method_decorator(require_global_staff) + def get(self, request): + """ + Render get view. + """ + return self.render_response() + + def validate_course_key(self, course_key, branch=ModuleStoreEnum.BranchName.draft): + """ + Validates the course_key that would be used by maintenance app views. + + Arguments: + course_key (string): a course key + branch: a course locator branch, default value is ModuleStoreEnum.BranchName.draft . + values can be either ModuleStoreEnum.BranchName.draft or ModuleStoreEnum.BranchName.published. + + Returns: + course_usage_key (CourseLocator): course usage locator + """ + if not course_key: + raise ValidationError(COURSE_KEY_ERROR_MESSAGES['empty_course_key']) + + course_usage_key = CourseKey.from_string(course_key) + + if not modulestore().has_course(course_usage_key): + raise ItemNotFoundError(COURSE_KEY_ERROR_MESSAGES['course_key_not_found']) + + # get branch specific locator + course_usage_key = course_usage_key.for_branch(branch) + + return course_usage_key + + +class ForcePublishCourseView(MaintenanceBaseView): + """ + View for force publishing state of the course, used by the global staff. + + This view uses `force_publish_course` method of modulestore which publishes the draft state of the course. After + the course has been forced published, both draft and publish draft point to same location. + """ + + def __init__(self): + super(ForcePublishCourseView, self).__init__(MAINTENANCE_VIEWS['force_publish_course']) + self.context.update({ + 'current_versions': [], + 'updated_versions': [], + 'form_data': { + 'course_id': '', + 'is_dry_run': True + } + }) + + def get_course_branch_versions(self, versions): + """ + Returns a dict containing unicoded values of draft and published draft versions. + """ + return { + 'draft-branch': unicode(versions['draft-branch']), + 'published-branch': unicode(versions['published-branch']) + } + + @transaction.atomic + @method_decorator(require_global_staff) + def post(self, request): + """ + This method force publishes a course if dry-run argument is not selected. If dry-run is selected, this view + shows possible outcome if the `force_publish_course` modulestore method is executed. + + Arguments: + course_id (string): a request parameter containing course id + is_dry_run (string): a request parameter containing dry run value. + It is obtained from checkbox so it has either values 'on' or ''. + """ + course_id = request.POST.get('course-id') + + self.context.update({ + 'form_data': { + 'course_id': course_id + } + }) + + try: + course_usage_key = self.validate_course_key(course_id) + except InvalidKeyError: + self.context['error'] = True + self.context['msg'] = COURSE_KEY_ERROR_MESSAGES['invalid_course_key'] + except ItemNotFoundError as exc: + self.context['error'] = True + self.context['msg'] = exc.message + except ValidationError as exc: + self.context['error'] = True + self.context['msg'] = exc.message + + if self.context['error']: + return self.render_response() + + source_store = modulestore()._get_modulestore_for_courselike(course_usage_key) # pylint: disable=protected-access + if not hasattr(source_store, 'force_publish_course'): + self.context['msg'] = _('Force publishing course is not supported with old mongo courses.') + log.warning( + 'Force publishing course is not supported with old mongo courses. \ + %s attempted to force publish the course %s.', + request.user, + course_id, + exc_info=True + ) + return self.render_response() + + current_versions = self.get_course_branch_versions(get_course_versions(course_id)) + + # if publish and draft are NOT different + if current_versions['published-branch'] == current_versions['draft-branch']: + self.context['msg'] = _('Course is already in published state.') + log.warning( + 'Course is already in published state. %s attempted to force publish the course %s.', + request.user, + course_id, + exc_info=True + ) + return self.render_response() + + self.context['current_versions'] = current_versions + log.info( + '%s dry ran force publish the course %s.', + request.user, + course_id, + exc_info=True + ) + return self.render_response() diff --git a/cms/envs/common.py b/cms/envs/common.py index 20efab36b0..9b5da06e77 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -827,6 +827,9 @@ INSTALLED_APPS = ( 'openedx.core.djangoapps.coursetalk', # not used in cms (yet), but tests run 'xblock_config', + # Maintenance tools + 'maintenance', + # Tracking 'track', 'eventtracking.django.apps.EventTrackingConfig', diff --git a/cms/static/js/maintenance/force_publish_course.js b/cms/static/js/maintenance/force_publish_course.js new file mode 100644 index 0000000000..b1d0209774 --- /dev/null +++ b/cms/static/js/maintenance/force_publish_course.js @@ -0,0 +1,82 @@ +define([ // jshint ignore:line + 'jquery', + 'underscore', + 'gettext', + 'common/js/components/utils/view_utils', + 'edx-ui-toolkit/js/utils/string-utils', + "edx-ui-toolkit/js/utils/html-utils", + 'text!templates/maintenance/force-published-course-response.underscore' +], +function($, _, gettext, ViewUtils, StringUtils, HtmlUtils, ForcePublishedTemplate) { + 'use strict'; + return function (maintenanceViewURL) { + + // Reset values + $('#reset-button').click(function (e) { + e.preventDefault(); + $('#course-id').val(''); + $('#dry-run').prop('checked', true); + // clear out result container + $('#result-container').html(''); + }); + + var showError = function(containerElSelector, error){ + var errorWrapperElSelector = containerElSelector + ' .wrapper-error'; + var errorHtml = '
' + error + '
'; + HtmlUtils.setHtml( + $(errorWrapperElSelector), + HtmlUtils.HTML(errorHtml) + ); + $(errorWrapperElSelector).css('display', 'inline-block'); + $(errorWrapperElSelector).fadeOut(5000); + }; + + $('form#force_publish').submit(function(event) { + + event.preventDefault(); + + // clear out result container + $('#result-container').html(''); + + var submitButton = $('#submit_force_publish'), + deferred = new $.Deferred(), + promise = deferred.promise(); + ViewUtils.disableElementWhileRunning(submitButton, function() { return promise; }); + + var data = $('#force_publish').serialize(); + + $.ajax({ + type:'POST', + url: maintenanceViewURL, + dataType: 'json', + data: data, + }) + .done(function(response) { + if(response.error){ + showError('#course-id-container', response.msg); + } + else { + if(response.msg) { + showError('#result-error', response.msg); + } + else{ + var attrs = $.extend({}, response, {StringUtils: StringUtils}); + HtmlUtils.setHtml( + $('#result-container'), + HtmlUtils.template(ForcePublishedTemplate)(attrs) + ); + } + } + }) + .fail(function(response) { // jshint ignore:line + // response.responseText here because it would show some strange output, it may output Traceback + // sometimes if unexpected issue arises. Better to show just internal error when getting 500 error. + showError('#result-error', gettext('Internal Server Error.')); + }) + .always(function(response) { // jshint ignore:line + deferred.resolve(); + }); + }); + }; +}); + diff --git a/cms/static/sass/_build-v1.scss b/cms/static/sass/_build-v1.scss index e65d9ee191..8df2eae508 100644 --- a/cms/static/sass/_build-v1.scss +++ b/cms/static/sass/_build-v1.scss @@ -68,6 +68,7 @@ @import 'views/group-configuration'; @import 'views/video-upload'; @import 'views/certificates'; +@import 'views/maintenance'; // +Base - Contexts // ==================== diff --git a/cms/static/sass/views/_maintenance.scss b/cms/static/sass/views/_maintenance.scss new file mode 100644 index 0000000000..8f18be8246 --- /dev/null +++ b/cms/static/sass/views/_maintenance.scss @@ -0,0 +1,71 @@ +.maintenance-header { + text-align: center; + margin-top: 50px; + + h2 { + margin-bottom: 10px; + } +} +.maintenance-content { + padding: 3rem 0; + .maintenance-list { + max-width: 1280px; + margin: 0 auto; + .view-list-container { + padding: 10px 15px; + background-color: #fff; + border-bottom: 1px solid #ddd; + &:hover { + background-color: #fafafa; + } + .view-name { + display: inline-block; + width: 20%; + float: left; + } + .view-desc { + display: inline-block; + width: 80%; + font-size: 15px; + } + } + } + .maintenance-form { + width: 60%; + margin: auto; + .result-list { + height: calc(100vh - 200px); + overflow: auto; + } + .result{ + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); + margin-top: 15px; + padding: 15px 30px; + background: #f9f9f9; + } + li { + font-size: 13px; + line-height: 9px; + } + .actions { + text-align: right; + } + .field-radio div { + display: inline-block; + margin-right: 10px; + } + div.error { + color: #F00; + margin-top: 10px; + font-size: 13px; + } + div.head-output { + font-size: 13px; + margin-bottom: 10px; + } + div.main-output { + color: #0A0; + font-size: 15px; + } + } +} diff --git a/cms/templates/js/maintenance/force-published-course-response.underscore b/cms/templates/js/maintenance/force-published-course-response.underscore new file mode 100644 index 0000000000..d77059ca53 --- /dev/null +++ b/cms/templates/js/maintenance/force-published-course-response.underscore @@ -0,0 +1,14 @@ +
+
+ <%- gettext('You have done a dry run of force publishing the course. Nothing has changed. Had you run it, the following course versions would have been change.') %> +
+
+ <%= StringUtils.interpolate( + gettext('The published branch version, {published}, was reset to the draft branch version, {draft}.'), + { + published: current_versions['published-branch'], + draft: current_versions['draft-branch'] + }) + %> +
+
diff --git a/cms/templates/maintenance/_force_publish_course.html b/cms/templates/maintenance/_force_publish_course.html new file mode 100644 index 0000000000..8066e1e40a --- /dev/null +++ b/cms/templates/maintenance/_force_publish_course.html @@ -0,0 +1,33 @@ +<%page expression_filter="h"/> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> +
+
+ +
+
+ ${_("Required data to force publish course.")} +
+
+ + +
${_('course-v1:edX+DemoX+Demo_Course')}
+
+
+
+
+
+
+ + + +
+
+
+
+
diff --git a/cms/templates/maintenance/base.html b/cms/templates/maintenance/base.html new file mode 100644 index 0000000000..d0691063c2 --- /dev/null +++ b/cms/templates/maintenance/base.html @@ -0,0 +1,21 @@ +<%page expression_filter="h"/> +<%inherit file="../base.html" /> +<%def name='online_help_token()'><% return 'maintenance' %> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +%> +<%block name="content"> +
+
+

+ + ${_('Maintenance Dashboard')} + +

+ <%block name="viewtitle"> + +
+<%block name="viewcontent"> + diff --git a/cms/templates/maintenance/container.html b/cms/templates/maintenance/container.html new file mode 100644 index 0000000000..0b6d22dc7f --- /dev/null +++ b/cms/templates/maintenance/container.html @@ -0,0 +1,28 @@ +<%page expression_filter="h"/> +<%inherit file="base.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.core.urlresolvers import reverse +from openedx.core.djangolib.js_utils import js_escaped_string +%> +<%block name="title">${view['name']} +<%block name="viewtitle"> +

+ ${view['name']} +

+ + +<%block name="js_extra"> + + + +<%block name="viewcontent"> +
+ <%include file="_${view['slug']}.html"/> + <%block name="requirejs"> + require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) { + MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}"); + }); + +
+ diff --git a/cms/templates/maintenance/index.html b/cms/templates/maintenance/index.html new file mode 100644 index 0000000000..f1f3224fe1 --- /dev/null +++ b/cms/templates/maintenance/index.html @@ -0,0 +1,20 @@ +<%page expression_filter="h"/> +<%inherit file="base.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +%> +<%block name="title">${_('Maintenance Dashboard')} +<%block name="viewcontent"> +
+
    + % for view in views.values(): +
  • + ${view['name']} + ${view['description']} +
  • + % endfor +
+
+ diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html index bebc2e21d0..d118a25c18 100644 --- a/cms/templates/widgets/user_dropdown.html +++ b/cms/templates/widgets/user_dropdown.html @@ -4,6 +4,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ + from student.roles import GlobalStaff %> % if uses_pattern_library: @@ -45,6 +46,11 @@ + % if GlobalStaff().has_user(user): + + % endif diff --git a/cms/urls.py b/cms/urls.py index dcab9cf6c3..d539456efd 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -181,6 +181,12 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'): 'contentstore.views.certificates.certificates_list_handler') ) +# Maintenance Dashboard +urlpatterns += patterns( + '', + url(r'^maintenance/', include('maintenance.urls', namespace='maintenance')), +) + urlpatterns += ( # These views use a configuration model to determine whether or not to # display the Programs authoring app. If disabled, a 404 is returned. diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index 86a73fc161..1c3e9fe1f9 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -4,12 +4,13 @@ import sys from functools import wraps from django.conf import settings +from django.contrib.auth.decorators import login_required from django.core.cache import caches from django.core.validators import ValidationError, validate_email from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import server_error from django.http import (Http404, HttpResponse, HttpResponseNotAllowed, - HttpResponseServerError) + HttpResponseServerError, HttpResponseForbidden) import dogstats_wrapper as dog_stats_api from edxmako.shortcuts import render_to_response import zendesk @@ -21,6 +22,8 @@ import track.views from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from student.roles import GlobalStaff + log = logging.getLogger(__name__) @@ -44,6 +47,21 @@ def ensure_valid_course_key(view_func): return inner +def require_global_staff(func): + """View decorator that requires that the user have global staff permissions. """ + @wraps(func) + def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring + if GlobalStaff().has_user(request.user): + return func(request, *args, **kwargs) + else: + return HttpResponseForbidden( + u"Must be {platform_name} staff to perform this action.".format( + platform_name=settings.PLATFORM_NAME + ) + ) + return login_required(wrapped) + + @requires_csrf_token def jsonable_server_error(request, template_name='500.html'): """ diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 9f3b8ea674..ea870713f5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -35,6 +35,7 @@ from util.file import ( FileValidationException, UniversalNewlineIterator ) from util.json_request import JsonResponse, JsonResponseBadRequest +from util.views import require_global_staff from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features from microsite_configuration import microsite @@ -207,20 +208,6 @@ def require_level(level): return decorator -def require_global_staff(func): - """View decorator that requires that the user have global staff permissions. """ - def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring - if GlobalStaff().has_user(request.user): - return func(request, *args, **kwargs) - else: - return HttpResponseForbidden( - u"Must be {platform_name} staff to perform this action.".format( - platform_name=theming_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) - ) - ) - return wrapped - - def require_sales_admin(func): """ Decorator for checking sales administrator access before executing an HTTP endpoint. This decorator