Basic architecture of Maintenance App - SUST-19, SUST-42. Implement Force Publish Course view. SUST-46
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
0
cms/djangoapps/maintenance/__init__.py
Normal file
0
cms/djangoapps/maintenance/__init__.py
Normal file
248
cms/djangoapps/maintenance/tests.py
Normal file
248
cms/djangoapps/maintenance/tests.py
Normal file
@@ -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)
|
||||
13
cms/djangoapps/maintenance/urls.py
Normal file
13
cms/djangoapps/maintenance/urls.py
Normal file
@@ -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'),
|
||||
)
|
||||
213
cms/djangoapps/maintenance/views.py
Normal file
213
cms/djangoapps/maintenance/views.py
Normal file
@@ -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()
|
||||
@@ -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',
|
||||
|
||||
82
cms/static/js/maintenance/force_publish_course.js
Normal file
82
cms/static/js/maintenance/force_publish_course.js
Normal file
@@ -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 = '<div class="error" aria-live="polite" id="course-id-error">' + error + '</div>';
|
||||
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();
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
@import 'views/certificates';
|
||||
@import 'views/maintenance';
|
||||
|
||||
// +Base - Contexts
|
||||
// ====================
|
||||
|
||||
71
cms/static/sass/views/_maintenance.scss
Normal file
71
cms/static/sass/views/_maintenance.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="result">
|
||||
<div class="head-output">
|
||||
<%- 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.') %>
|
||||
</div>
|
||||
<div class="main-output">
|
||||
<%= 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']
|
||||
})
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
33
cms/templates/maintenance/_force_publish_course.html
Normal file
33
cms/templates/maintenance/_force_publish_course.html
Normal file
@@ -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
|
||||
%>
|
||||
<div id="force-published-form" class="wrap-instructor-info studio-view maintenance-form">
|
||||
<form id="force_publish" class="form-create" method="post">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="wrapper-form">
|
||||
<fieldset>
|
||||
<legend class="sr">${_("Required data to force publish course.")}</legend>
|
||||
<div class="list-input">
|
||||
<div id="course-id-container" class="field text required">
|
||||
<label for="course-id">${_('Course ID')}</label>
|
||||
<input id="course-id" type="text" name="course-id" aria-describedby="course-id-desc" required />
|
||||
<div id="course-id-desc" class="tip tip-stacked">${_('course-v1:edX+DemoX+Demo_Course')}</div>
|
||||
<div class="wrapper-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" id="submit_force_publish" class="action action-primary">${_('Force Publish Course')}
|
||||
</button>
|
||||
<button id="reset-button" class="action action-secondary action-cancel"
|
||||
aria-describedby="reset-values-desc">${_('Reset')}</button>
|
||||
<span id="reset-values-desc" class="is-hidden">${_('Reset values')}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result-error"><div class="wrapper-error"></div></div>
|
||||
<div id="result-container" class="result-container"></div>
|
||||
</div>
|
||||
21
cms/templates/maintenance/base.html
Normal file
21
cms/templates/maintenance/base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="../base.html" />
|
||||
<%def name='online_help_token()'><% return 'maintenance' %></%def>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="content">
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="maintenance-header">
|
||||
<h2>
|
||||
<a href="${reverse('maintenance:maintenance_index')}">
|
||||
<span>${_('Maintenance Dashboard')}</span>
|
||||
</a>
|
||||
</h2>
|
||||
<%block name="viewtitle">
|
||||
</%block>
|
||||
</div>
|
||||
<%block name="viewcontent"></%block>
|
||||
</%block>
|
||||
28
cms/templates/maintenance/container.html
Normal file
28
cms/templates/maintenance/container.html
Normal file
@@ -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>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${view['name']}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script src="${static.url('js/maintenance/force_publish.js')}"></script>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<%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}");
|
||||
});
|
||||
</%block>
|
||||
</section>
|
||||
</%block>
|
||||
20
cms/templates/maintenance/index.html
Normal file
20
cms/templates/maintenance/index.html
Normal file
@@ -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>
|
||||
<%block name="viewcontent">
|
||||
<div class="container maintenance-content">
|
||||
<ul class="maintenance-list">
|
||||
% for view in views.values():
|
||||
<li class="view-list-container">
|
||||
<a class="view-name" href='${reverse(view["url"])}'>${view['name']}</a>
|
||||
<span class="view-desc">${view['description']}</span>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -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 @@
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
% if GlobalStaff().has_user(user):
|
||||
<li class="nav-item">
|
||||
<a href="${reverse('maintenance:maintenance_index')}">${_("Maintenance")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-account-signout">
|
||||
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
|
||||
</li>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user