Revert "fix: Remove pointless Maintenance and Announcement apps (#35852)"

This reverts commit 9274852f2d.
This commit is contained in:
Kyle McCormick
2025-01-14 15:37:48 -05:00
parent d29ff63e2c
commit 569c2d9ad2
36 changed files with 1637 additions and 5 deletions

View File

@@ -238,6 +238,7 @@
"cms/djangoapps/cms_user_tasks/",
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
"cms/djangoapps/xblock_config/",

View File

View File

@@ -0,0 +1,311 @@
"""
Tests for the maintenance app views.
"""
import json
import ddt
from django.conf import settings
from django.urls import reverse
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
from openedx.features.announcements.models import Announcement
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
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().setUp()
self.user = AdminFactory()
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
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().setUp()
self.user = AdminFactory()
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
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().tearDown()
@ddt.ddt
class MaintenanceViewAccessTests(MaintenanceViewTestCase):
"""
Tests for access control of maintenance views.
"""
@ddt.data(*MAINTENANCE_URLS)
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=settings.LOGIN_URL,
original_url=url,
)
# Studio login redirects to LMS login
self.assertRedirects(response, redirect_url, target_status_code=302)
@ddt.data(*MAINTENANCE_URLS)
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)
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=self.TEST_PASSWORD)
login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD)
self.assertTrue(login_success)
response = self.client.get(url)
self.assertContains(
response,
f'Must be {settings.PLATFORM_NAME} staff to perform this action.',
status_code=403
)
@ddt.ddt
class TestForcePublish(MaintenanceViewTestCase):
"""
Tests for the force publish view.
"""
def setUp(self):
super().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()
# Add some changes to course
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
self.store.create_child(
self.user.id,
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_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)
# now course is published, we should get `already published course` error.
self.verify_error_message(
data={'course-id': str(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(str(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': str(course.id)
}
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response_data = json.loads(response.content.decode('utf-8'))
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)
@ddt.ddt
class TestAnnouncementsViews(MaintenanceViewTestCase):
"""
Tests for the announcements edit view.
"""
def setUp(self):
super().setUp()
self.admin = AdminFactory.create(
email='staff@edx.org',
username='admin',
password=self.TEST_PASSWORD
)
self.client.login(username=self.admin.username, password=self.TEST_PASSWORD)
self.non_staff_user = UserFactory.create(
email='test@edx.org',
username='test',
password=self.TEST_PASSWORD
)
def test_index(self):
"""
Test create announcement view
"""
url = reverse("maintenance:announcement_index")
response = self.client.get(url)
self.assertContains(response, '<div class="announcement-container">')
def test_create(self):
"""
Test create announcement view
"""
url = reverse("maintenance:announcement_create")
self.client.post(url, {"content": "Test Create Announcement", "active": True})
result = Announcement.objects.filter(content="Test Create Announcement").exists()
self.assertTrue(result)
def test_edit(self):
"""
Test edit announcement view
"""
announcement = Announcement.objects.create(content="test")
announcement.save()
url = reverse("maintenance:announcement_edit", kwargs={"pk": announcement.pk})
response = self.client.get(url)
self.assertContains(response, '<div class="wrapper-form announcement-container">')
self.client.post(url, {"content": "Test Edit Announcement", "active": True})
announcement = Announcement.objects.get(pk=announcement.pk)
self.assertEqual(announcement.content, "Test Edit Announcement")
def test_delete(self):
"""
Test delete announcement view
"""
announcement = Announcement.objects.create(content="Test Delete")
announcement.save()
url = reverse("maintenance:announcement_delete", kwargs={"pk": announcement.pk})
self.client.post(url)
result = Announcement.objects.filter(content="Test Edit Announcement").exists()
self.assertFalse(result)
def _test_403(self, viewname, kwargs=None):
url = reverse("maintenance:%s" % viewname, kwargs=kwargs)
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_authorization(self):
self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD)
announcement = Announcement.objects.create(content="Test Delete")
announcement.save()
self._test_403("announcement_index")
self._test_403("announcement_create")
self._test_403("announcement_edit", {"pk": announcement.pk})
self._test_403("announcement_delete", {"pk": announcement.pk})

View File

@@ -0,0 +1,25 @@
"""
URLs for the maintenance app.
"""
from django.urls import path, re_path
from .views import (
AnnouncementCreateView,
AnnouncementDeleteView,
AnnouncementEditView,
AnnouncementIndexView,
ForcePublishCourseView,
MaintenanceIndexView
)
app_name = 'cms.djangoapps.maintenance'
urlpatterns = [
path('', MaintenanceIndexView.as_view(), name='maintenance_index'),
re_path(r'^force_publish_course/?$', ForcePublishCourseView.as_view(), name='force_publish_course'),
re_path(r'^announcements/(?P<page>\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'),
path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'),
re_path(r'^announcements/edit/(?P<pk>\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'),
path('announcements/delete/<int:pk>', AnnouncementDeleteView.as_view(), name='announcement_delete'),
]

View File

@@ -0,0 +1,301 @@
"""
Views for the maintenance app.
"""
import logging
from django.core.validators import ValidationError
from django.db import transaction
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.generic import View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.list import ListView
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.util.json_request import JsonResponse
from common.djangoapps.util.views import require_global_staff
from openedx.features.announcements.forms import AnnouncementForm
from openedx.features.announcements.models import Announcement
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
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'
),
},
'announcement_index': {
'url': 'maintenance:announcement_index',
'name': _('Edit Announcements'),
'slug': 'announcement_index',
'description': _(
'This view shows the announcement editor to create or alter announcements that are shown on the right'
'side of the dashboard.'
),
},
}
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):
super().__init__()
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.headers.get('x-requested-with') == 'XMLHttpRequest':
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().__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': str(versions['draft-branch']),
'published-branch': str(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'] = str(exc)
except ValidationError as exc:
self.context['error'] = True
self.context['msg'] = str(exc)
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()
class AnnouncementBaseView(View):
"""
Base view for Announcements pages
"""
@method_decorator(require_global_staff)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
class AnnouncementIndexView(ListView, MaintenanceBaseView):
"""
View for viewing the announcements shown on the dashboard, used by the global staff.
"""
model = Announcement
object_list = Announcement.objects.order_by('-active')
context_object_name = 'announcement_list'
paginate_by = 8
def __init__(self):
super().__init__(MAINTENANCE_VIEWS['announcement_index'])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['view'] = MAINTENANCE_VIEWS['announcement_index']
return context
@method_decorator(require_global_staff)
def get(self, request, *args, **kwargs):
context = self.get_context_data()
return render_to_response(self.template, context)
class AnnouncementEditView(UpdateView, AnnouncementBaseView):
"""
View for editing an announcement.
"""
model = Announcement
form_class = AnnouncementForm
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_edit.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action_url'] = reverse('maintenance:announcement_edit', kwargs={'pk': context['announcement'].pk})
return context
class AnnouncementCreateView(CreateView, AnnouncementBaseView):
"""
View for creating an announcement.
"""
model = Announcement
form_class = AnnouncementForm
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_edit.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['action_url'] = reverse('maintenance:announcement_create')
return context
class AnnouncementDeleteView(DeleteView, AnnouncementBaseView):
"""
View for deleting an announcement.
"""
model = Announcement
success_url = reverse_lazy('maintenance:announcement_index')
template_name = '/maintenance/_announcement_delete.html'

View File

@@ -1699,6 +1699,8 @@ INSTALLED_APPS = [
# New (Learning-Core-based) XBlock runtime
'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig',
# Maintenance tools
'cms.djangoapps.maintenance',
'openedx.core.djangoapps.util.apps.UtilConfig',
# Tracking

View File

@@ -0,0 +1,83 @@
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'
],
function($, _, gettext, ViewUtils, StringUtils, HtmlUtils) {
'use strict';
return function(maintenanceViewURL) {
var showError;
// Reset values
$('#reset-button').click(function(e) {
e.preventDefault();
$('#course-id').val('');
$('#dry-run').prop('checked', true);
// clear out result container
$('#result-container').html('');
});
showError = function(containerElSelector, error) {
var errorWrapperElSelector, errorHtml;
errorWrapperElSelector = containerElSelector + ' .wrapper-error';
errorHtml = HtmlUtils.joinHtml(
HtmlUtils.HTML('<div class="error" aria-live="polite" id="course-id-error">'),
error,
HtmlUtils.HTML('</div>')
);
HtmlUtils.setHtml($(errorWrapperElSelector), HtmlUtils.HTML(errorHtml));
$(errorWrapperElSelector).css('display', 'inline-block');
$(errorWrapperElSelector).fadeOut(5000);
};
$('form#force_publish').submit(function(event) {
var attrs, forcePublishedTemplate, $submitButton, deferred, promise, data;
event.preventDefault();
// clear out result container
$('#result-container').html('');
$submitButton = $('#submit_force_publish');
deferred = new $.Deferred();
promise = deferred.promise();
data = $('#force_publish').serialize();
// disable submit button while executing.
ViewUtils.disableElementWhileRunning($submitButton, function() { return promise; });
$.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 {
attrs = $.extend({}, response, {StringUtils: StringUtils});
forcePublishedTemplate = HtmlUtils.template(
$('#force-published-course-response-tpl').text()
);
HtmlUtils.setHtml($('#result-container'), forcePublishedTemplate(attrs));
}
}
})
.fail(function() {
// 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() {
deferred.resolve();
});
});
};
});

View File

@@ -77,6 +77,7 @@
@import 'views/group-configuration';
@import 'views/video-upload';
@import 'views/certificates';
@import 'views/maintenance';
// +Base - Contexts
// ====================

View File

@@ -0,0 +1,104 @@
.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;
}
}
.announcement-container {
width: 100%;
text-align: center;
.announcement-item {
display: inline-block;
max-width: 300px;
min-width: 300px;
margin: 15px;
.announcement-content {
background-color: $body-bg;
text-align: center;
padding: 22px 33px;
}
}
}
}

View File

@@ -0,0 +1,40 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%block name="title">${_('Delete Announcement')}</%block>
<%block name="viewtitle">
<h3 class="info-course">
<span>${_('Delete Announcement')}</span>
</h3>
</%block>
<%block name="viewcontent">
<section class="container maintenance-content">
<div id="delete-announcement-form" class="studio-view maintenance-form">
<div class="container-message wrapper-message">
<div class="message has-warnings" aria-hidden="true">
<p class="warning">
<span class="icon fa fa-warning"></span>
${_('Are you sure you want to delete this Announcement?')}
</p>
</div>
</div>
<form action="" method="post" class="form-create announcement-container">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
<div class="announcement-item">
<div class="announcement-content">
## xss-lint: disable=mako-invalid-html-filter
${object.content | n}
</div>
</div>
<div class="actions">
<button type="submit" class="action action-primary" value="Confirm">${_('Confirm')}</button>
</div>
</form>
</div>
</section>
</%block>

View File

@@ -0,0 +1,50 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%block name="title">${_('Edit Announcement')}</%block>
<%block name="viewtitle">
<h3 class="info-course">
<span>${_('Edit Announcement')}</span>
</h3>
</%block>
<%block name="viewcontent">
<section class="container maintenance-content">
<div class="container studio-view maintenance-form">
<form class="form-create" method="post" action="${action_url}">
<div class="wrapper-form announcement-container">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
<div class="announcement-item">
## xss-lint: disable=mako-invalid-html-filter
${form.as_p() | n}
</div>
<div class="actions">
<button type="submit" class="action action-primary">${_('Save')}</button>
</div>
</div>
</form>
</div>
</section>
</%block>
<%block name="header_extras">
<script src="${STATIC_URL}js/vendor/tinymce/js/tinymce/tinymce.full.min.js"></script>
<script type="text/javascript">
tinymce.init({
selector: "#id_content",
height: 450,
skin: "studio-tmce5",
branding: false,
plugins: [
"advlist autolink lists link image charmap print anchor",
"searchreplace visualblocks code",
"insertdatetime media table contextmenu paste"
],
toolbar: "undo redo styleselect bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent link image"
});
</script>
</%block>

View File

@@ -0,0 +1,59 @@
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<div class="studio-view maintenance-form">
<div class="form-create">
<div class="announcement-container">
% for announcement in announcement_list:
<div class="announcement-item">
<div class="announcement-content">
## xss-lint: disable=mako-invalid-html-filter
${announcement.content | n}
</div>
<div class="actions-list">
<span>
% if announcement.active:
Active <span class="icon fa fa-check-square-o" aria-hidden="true" />
% else:
Inactive <span class="icon fa fa-square-o" aria-hidden="true" />
% endif
</span>
<a class="action-item announcement-edit"
href="${ reverse('maintenance:announcement_edit', kwargs={'pk': announcement.pk}) }">
<button class="btn-default">Edit</button>
</a>
<a class="action-item announcement-delete"
href="${ reverse('maintenance:announcement_delete', kwargs={'pk': announcement.pk}) }">
<button class="btn-default">Delete</button>
</a>
</div>
</div>
% endfor
</div>
<div class="actions">
<a href="${ reverse('maintenance:announcement_create') }">
<button class="action action-primary">${_('Create New')}</button>
</a>
% if is_paginated:
% if page_obj.has_previous():
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.previous_page_number()}) }">
<button class="action action-secondary">${_('previous')}</button>
</a>
% endif
% if page_obj.has_next():
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.next_page_number()}) }">
<button class="action action-secondary">${_('next')}</button>
</a>
% endif
% endif
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<%page expression_filter="h"/>
<%namespace name='static' file='../static_content.html'/>
<%!
from django.utils.translation import gettext 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>

View 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.urls import reverse
from django.utils.translation import gettext 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>

View File

@@ -0,0 +1,33 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/>
<%!
from django.urls 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="viewcontent">
<section class="container maintenance-content">
<%include file="_${view['slug']}.html"/>
</section>
</%block>
<%block name="header_extras">
% for template_name in ["force-published-course-response"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/maintenance/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="requirejs">
require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) {
MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}");
});
</%block>

View 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 gettext as _
from django.urls 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>

View File

@@ -21,6 +21,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="${settings.FRONTEND_LOGOUT_URL}">${_("Sign Out")}</a>
</li>

View File

@@ -276,6 +276,8 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'):
certificates_list_handler, name='certificates_list_handler')
]
# Maintenance Dashboard
urlpatterns.append(path('maintenance/', include('cms.djangoapps.maintenance.urls', namespace='maintenance')))
if settings.DEBUG:
try:

View File

@@ -67,6 +67,7 @@
// features
@import 'features/bookmarks-v1';
@import "features/announcements";
@import 'features/_unsupported-browser-alert';
@import 'features/content-type-gating';
@import 'features/course-duration-limits';

View File

@@ -0,0 +1,28 @@
// lms - features - announcements
// ====================
.announcements-list {
display: inline-block;
width: 100%;
.announcement {
background-color: $course-profile-bg;
align-content: center;
text-align: center;
padding: 22px 33px;
margin-bottom: 15px;
}
.announcement-button {
display: inline-block;
padding: 3px 10px;
font-size: 0.75rem;
}
.prev {
float: left;
}
.next {
float: right;
}
}

View File

@@ -0,0 +1,32 @@
"""
Announcements Application Configuration
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
class AnnouncementsConfig(AppConfig):
"""
Application Configuration for Announcements
"""
name = 'openedx.features.announcements'
plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: 'announcements',
PluginURLs.REGEX: '^announcements/',
PluginURLs.RELATIVE_PATH: 'urls',
}
},
PluginSettings.CONFIG: {
ProjectType.LMS: {
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'},
SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'},
}
}
}

View File

@@ -0,0 +1,20 @@
"""
Forms for the Announcement Editor
"""
from django import forms
from .models import Announcement
class AnnouncementForm(forms.ModelForm):
"""
Form for editing Announcements
"""
content = forms.CharField(widget=forms.Textarea, label='', required=False)
active = forms.BooleanField(initial=True, required=False)
class Meta:
model = Announcement
fields = ['content', 'active']

View File

@@ -0,0 +1,22 @@
"""
Models for Announcements
"""
from django.db import models
class Announcement(models.Model):
"""
Site-wide announcements to be displayed on the dashboard
.. no_pii:
"""
class Meta:
app_label = 'announcements'
content = models.CharField(max_length=1000, null=False, default="lorem ipsum")
active = models.BooleanField(default=True)
def __str__(self):
return self.content

View File

@@ -0,0 +1,21 @@
"""Common settings for Announcements"""
def plugin_settings(settings):
"""
Common settings for Announcements
.. toggle_name: FEATURES['ENABLE_ANNOUNCEMENTS']
.. toggle_implementation: SettingDictToggle
.. toggle_default: False
.. toggle_description: This feature can be enabled to show system wide announcements
on the sidebar of the learner dashboard. Announcements can be created by Global Staff
users on maintenance dashboard of studio. Maintenance dashboard can accessed at
https://{studio.domain}/maintenance
.. toggle_warning: TinyMCE is needed to show an editor in the studio.
.. toggle_use_cases: open_edx
.. toggle_creation_date: 2017-11-08
.. toggle_tickets: https://github.com/openedx/edx-platform/pull/16496
"""
settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = False
# Configure number of announcements to show per page
settings.FEATURES['ANNOUNCEMENTS_PER_PAGE'] = 5

View File

@@ -0,0 +1,8 @@
"""Test settings for Announcements"""
def plugin_settings(settings):
"""
Test settings for Announcements
"""
settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = True

View File

@@ -0,0 +1,141 @@
// eslint-disable-next-line max-classes-per-file
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import {Button} from '@edx/paragon';
import $ from 'jquery';
class AnnouncementSkipLink extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
$.get('/announcements/page/1')
.then(data => {
this.setState({
count: data.count
});
});
}
render() {
return (<div>{'Skip to list of ' + this.state.count + ' announcements'}</div>);
}
}
// eslint-disable-next-line react/prefer-stateless-function
class Announcement extends React.Component {
render() {
return (
<div
className="announcement"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: this.props.content}}
/>
);
}
}
Announcement.propTypes = {
content: PropTypes.string.isRequired,
};
class AnnouncementList extends React.Component {
constructor(props) {
super(props);
this.state = {
page: 1,
announcements: [],
// eslint-disable-next-line react/no-unused-state
num_pages: 0,
has_prev: false,
has_next: false,
start_index: 0,
end_index: 0,
};
}
retrievePage(page) {
$.get('/announcements/page/' + page)
.then(data => {
this.setState({
announcements: data.announcements,
has_next: data.next,
has_prev: data.prev,
// eslint-disable-next-line react/no-unused-state
num_pages: data.num_pages,
count: data.count,
start_index: data.start_index,
end_index: data.end_index,
page: page
});
});
}
renderPrevPage() {
this.retrievePage(this.state.page - 1);
}
renderNextPage() {
this.retrievePage(this.state.page + 1);
}
// eslint-disable-next-line react/no-deprecated, react/sort-comp
componentWillMount() {
this.retrievePage(this.state.page);
}
render() {
var children = this.state.announcements.map(
// eslint-disable-next-line react/no-array-index-key
(announcement, index) => <Announcement key={index} content={announcement.content} />
);
if (this.state.has_prev) {
var prev_button = (
<div>
<Button
className={['announcement-button', 'prev']}
onClick={() => this.renderPrevPage()}
label="← previous"
/>
<span className="sr-only">{this.state.start_index + ' - ' + this.state.end_index + ') of ' + this.state.count}</span>
</div>
);
}
if (this.state.has_next) {
var next_button = (
<div>
<Button
className={['announcement-button', 'next']}
onClick={() => this.renderNextPage()}
label="next →"
/>
<span className="sr-only">{this.state.start_index + ' - ' + this.state.end_index + ') of ' + this.state.count}</span>
</div>
);
}
return (
<div className="announcements-list">
{children}
{prev_button}
{next_button}
</div>
);
}
}
export default class AnnouncementsView {
constructor() {
ReactDOM.render(
<AnnouncementList />,
document.getElementById('announcements'),
);
ReactDOM.render(
<AnnouncementSkipLink />,
document.getElementById('announcements-skip'),
);
}
}
export {AnnouncementsView, AnnouncementList, AnnouncementSkipLink};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import renderer from 'react-test-renderer';
import testAnnouncements from './test-announcements.json';
import {AnnouncementSkipLink, AnnouncementList} from './Announcements';
describe('Announcements component', () => {
test('render skip link', () => {
const component = renderer.create(
<AnnouncementSkipLink />,
);
component.root.instance.setState({count: 10});
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('render test announcements', () => {
const component = renderer.create(
<AnnouncementList />,
);
component.root.instance.setState(testAnnouncements);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Announcements component render skip link 1`] = `
<div>
Skip to list of 10 announcements
</div>
`;
exports[`Announcements component render test announcements 1`] = `
<div
className="announcements-list"
>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Test Announcement 1",
}
}
/>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Bold <b>Announcement 2</b>",
}
}
/>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Test Announcement 3",
}
}
/>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Test Announcement 4",
}
}
/>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Test Announcement 5",
}
}
/>
<div
className="announcement"
dangerouslySetInnerHTML={
{
"__html": "Test Announcement 6",
}
}
/>
<div>
<button
className="announcement-button next btn"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
next →
</button>
<span
className="sr-only"
>
1 - 5) of 6
</span>
</div>
</div>
`;

View File

@@ -0,0 +1,17 @@
{
"announcements": [
{"content": "Test Announcement 1"},
{"content": "Bold <b>Announcement 2</b>"},
{"content": "Test Announcement 3"},
{"content": "Test Announcement 4"},
{"content": "Test Announcement 5"},
{"content": "Test Announcement 6"}
],
"has_next": true,
"has_prev": false,
"num_pages": 2,
"count": 6,
"start_index": 1,
"end_index": 5,
"page": 1
}

View File

@@ -0,0 +1,95 @@
"""
Unit tests for the announcements feature.
"""
import json
from unittest.mock import patch
from django.conf import settings
from django.test import TestCase
from django.test.client import Client
from django.urls import reverse
from common.djangoapps.student.tests.factories import AdminFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.features.announcements.models import Announcement
TEST_ANNOUNCEMENTS = [
("Active Announcement", True),
("Inactive Announcement", False),
("Another Test Announcement", True),
("<strong>Formatted Announcement</strong>", True),
("<a>Other Formatted Announcement</a>", True),
]
@skip_unless_lms
class TestGlobalAnnouncements(TestCase):
"""
Test Announcements in LMS
"""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
Announcement.objects.bulk_create([
Announcement(content=content, active=active)
for content, active in TEST_ANNOUNCEMENTS
])
def setUp(self):
super().setUp()
self.client = Client()
self.admin = AdminFactory.create(
email='staff@edx.org',
username='admin',
password='pass'
)
self.client.login(username=self.admin.username, password='pass')
@patch.dict(settings.FEATURES, {'ENABLE_ANNOUNCEMENTS': False})
def test_feature_flag_disabled(self):
"""Ensures that the default settings effectively disables the feature"""
response = self.client.get('/dashboard')
self.assertNotContains(response, 'AnnouncementsView')
self.assertNotContains(response, '<div id="announcements"')
def test_feature_flag_enabled(self):
"""Ensures that enabling the flag, enables the feature"""
response = self.client.get('/dashboard')
self.assertContains(response, 'AnnouncementsView')
def test_pagination(self):
url = reverse("announcements:page", kwargs={"page": 1})
response = self.client.get(url)
data = json.loads(response.content.decode('utf-8'))
assert data['num_pages'] == 1
## double the number of announcements to verify the number of pages increases
self.setUpTestData()
response = self.client.get(url)
data = json.loads(response.content.decode('utf-8'))
assert data['num_pages'] == 2
def test_active(self):
"""
Ensures that active announcements are visible on the dashboard
"""
url = reverse("announcements:page", kwargs={"page": 1})
response = self.client.get(url)
self.assertContains(response, "Active Announcement")
def test_inactive(self):
"""
Ensures that inactive announcements aren't visible on the dashboard
"""
url = reverse("announcements:page", kwargs={"page": 1})
response = self.client.get(url)
self.assertNotContains(response, "Inactive Announcement")
def test_formatted(self):
"""
Ensures that formatting in announcements is rendered properly
"""
url = reverse("announcements:page", kwargs={"page": 1})
response = self.client.get(url)
self.assertContains(response, "<strong>Formatted Announcement</strong>")

View File

@@ -0,0 +1,13 @@
"""
Defines URLs for announcements in the LMS.
"""
from django.contrib.auth.decorators import login_required
from django.urls import path
from .views import AnnouncementsJSONView
urlpatterns = [
path('page/<int:page>', login_required(AnnouncementsJSONView.as_view()),
name='page',
),
]

View File

@@ -0,0 +1,37 @@
"""
Views to show announcements.
"""
from django.conf import settings
from django.http import JsonResponse
from django.views.generic.list import ListView
from .models import Announcement
class AnnouncementsJSONView(ListView):
"""
View returning a page of announcements for the dashboard
"""
model = Announcement
object_list = Announcement.objects.filter(active=True)
paginate_by = settings.FEATURES.get('ANNOUNCEMENTS_PER_PAGE', 5)
def get(self, request, *args, **kwargs):
"""
Return active announcements as json
"""
context = self.get_context_data()
announcements = [{"content": announcement.content} for announcement in context['object_list']]
result = {
"announcements": announcements,
"next": context['page_obj'].has_next(),
"prev": context['page_obj'].has_previous(),
"start_index": context['page_obj'].start_index(),
"end_index": context['page_obj'].end_index(),
"count": context['paginator'].count,
"num_pages": context['paginator'].num_pages,
}
return JsonResponse(result)

View File

@@ -138,6 +138,7 @@ setup(
],
"lms.djangoapp": [
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",
"content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig",
"course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig",
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
@@ -156,6 +157,7 @@ setup(
"program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig",
],
"cms.djangoapp": [
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
"bookmarks = openedx.core.djangoapps.bookmarks.apps:BookmarksConfig",
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",

View File

@@ -133,6 +133,7 @@ module.exports = Merge.smart({
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
Currency: './openedx/features/course_experience/static/course_experience/js/currency.js',
AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx',
CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx',
// Common
@@ -193,19 +194,19 @@ module.exports = Merge.smart({
multiple: [
{ search: defineHeader, replace: '' },
{ search: defineFooter, replace: '' },
{
{
search: /(\/\* RequireJS) \*\//g,
replace(match, p1, offset, string) {
return p1;
}
},
{
{
search: /\/\* Webpack/g,
replace(match, p1, offset, string) {
return match + ' */';
}
},
{
{
search: /text!(.*?\.underscore)/g,
replace(match, p1, offset, string) {
return p1;
@@ -656,13 +657,13 @@ module.exports = Merge.smart({
// We used to have node: { fs: 'empty' } in this file,
// that is no longer supported. Adding this based on the recommendation in
// https://stackoverflow.com/questions/64361940/webpack-error-configuration-node-has-an-unknown-property-fs
//
//
// With this uncommented tests fail
// Tests failed in the following suites:
// * lms javascript
// * xmodule-webpack javascript
// Error: define cannot be used indirect
//
//
// fallback: {
// fs: false
// }