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 = '