feat!: Remove Studio Maintenance & Announcements (#37432)

The announcements editor was never ported to frontend-app-authoring, and
the announcements display was never ported to frontend-app-learner-dashboard.
This announcements feature is rarely used, undocumented, non-a11y-friendly, and
there were no volunteers to port it to the new frontends. It is the last
remaining part of the legacy Studio "Maintenance" dashboard. So, we are
removing it.

BREAKING CHANGE: This removes...
* Studio Maintenance dashboard legacy frontend
* Studio Edit Announcements legacy frontend
* The snippet of legacy learner dashboard which renders announcements
* openedx/features/announcements djangoapp
* The ENABLE_ANNOUNCEMENTS feature flag

Not removed:
* The announcements_announcement table from openedx/features/announcements .
  The table is most likely very small, as it is only populated by administrators. Removing
  it would be more labor for us and more risk of toil for operators than is worthwhile.

Closes: https://github.com/openedx/edx-platform/issues/36263
This commit is contained in:
Kyle McCormick
2025-10-10 12:48:00 -04:00
committed by GitHub
parent 09e86e24b2
commit 20bc7113e3
38 changed files with 0 additions and 1306 deletions

View File

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

View File

@@ -1,194 +0,0 @@
"""
Tests for the maintenance app views.
"""
import ddt
from django.conf import settings
from django.urls import reverse
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
from openedx.features.announcements.models import Announcement
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from .views import 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 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

@@ -1,23 +0,0 @@
"""
URLs for the maintenance app.
"""
from django.urls import path, re_path
from .views import (
AnnouncementCreateView,
AnnouncementDeleteView,
AnnouncementEditView,
AnnouncementIndexView,
MaintenanceIndexView
)
app_name = 'cms.djangoapps.maintenance'
urlpatterns = [
path('', MaintenanceIndexView.as_view(), name='maintenance_index'),
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

@@ -1,190 +0,0 @@
"""
Views for the maintenance app.
"""
import logging
from django.core.validators import ValidationError
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.edx.keys import CourseKey
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 = {
'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 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

@@ -1010,8 +1010,6 @@ 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

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

View File

@@ -1,104 +0,0 @@
.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

@@ -1,40 +0,0 @@
<%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

@@ -1,50 +0,0 @@
<%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

@@ -1,59 +0,0 @@
<%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

@@ -1,21 +0,0 @@
<%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

@@ -1,25 +0,0 @@
<%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="requirejs">
require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) {
MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}");
});
</%block>

View File

@@ -1,20 +0,0 @@
<%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,11 +21,6 @@
<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

@@ -284,8 +284,6 @@ 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,7 +67,6 @@
// 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

@@ -1,28 +0,0 @@
// 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

@@ -294,16 +294,6 @@ from common.djangoapps.student.models import CourseEnrollment
</div>
<div id="dashboard-search-results" class="search-results dashboard-search-results"></div>
% endif
<%block name="skip_links">
% if settings.FEATURES.get('ENABLE_ANNOUNCEMENTS'):
<a id="announcements-skip" class="nav-skip sr-only sr-only-focusable" href="#announcements">${_("Skip to list of announcements")}</a>
% endif
</%block>
% if settings.FEATURES.get('ENABLE_ANNOUNCEMENTS'):
<%include file='dashboard/_dashboard_announcements.html' />
% endif
</div>
</div>
</main>

View File

@@ -1,32 +0,0 @@
"""
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

@@ -1,20 +0,0 @@
"""
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

@@ -1,18 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('content', models.CharField(default='lorem ipsum', max_length=1000)),
('active', models.BooleanField(default=True)),
],
),
]

View File

@@ -1,22 +0,0 @@
"""
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

@@ -1,21 +0,0 @@
"""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.ENABLE_ANNOUNCEMENTS = False
# Configure number of announcements to show per page
settings.ANNOUNCEMENTS_PER_PAGE = 5

View File

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

View File

@@ -1,141 +0,0 @@
// 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

@@ -1,25 +0,0 @@
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

@@ -1,78 +0,0 @@
// 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

@@ -1,17 +0,0 @@
{
"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

@@ -1,95 +0,0 @@
"""
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

@@ -1,13 +0,0 @@
"""
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

@@ -1,37 +0,0 @@
"""
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

@@ -139,7 +139,6 @@ 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",
@@ -158,7 +157,6 @@ 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

@@ -134,7 +134,6 @@ module.exports = Merge.merge({
// Features
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