+
+
+ <%block name="viewtitle">
+ %block>
+
+<%block name="viewcontent">%block>
+%block>
diff --git a/cms/templates/maintenance/container.html b/cms/templates/maintenance/container.html
new file mode 100644
index 000000000000..319a57bfe995
--- /dev/null
+++ b/cms/templates/maintenance/container.html
@@ -0,0 +1,25 @@
+<%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">
+
+ ${view['name']}
+
+%block>
+
+<%block name="viewcontent">
+
+ <%include file="_${view['slug']}.html"/>
+
+%block>
+
+<%block name="requirejs">
+ require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) {
+ MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}");
+ });
+%block>
diff --git a/cms/templates/maintenance/index.html b/cms/templates/maintenance/index.html
new file mode 100644
index 000000000000..293cb90b4a9c
--- /dev/null
+++ b/cms/templates/maintenance/index.html
@@ -0,0 +1,20 @@
+<%page expression_filter="h"/>
+<%inherit file="base.html" />
+<%namespace name='static' file='../static_content.html'/>
+<%!
+from django.utils.translation import gettext as _
+from django.urls import reverse
+%>
+<%block name="title">${_('Maintenance Dashboard')}%block>
+<%block name="viewcontent">
+
+
+ % for view in views.values():
+ -
+ ${view['name']}
+ ${view['description']}
+
+ % endfor
+
+
+%block>
diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html
index 3fc0934b0db7..0ec00257ffe1 100644
--- a/cms/templates/widgets/user_dropdown.html
+++ b/cms/templates/widgets/user_dropdown.html
@@ -21,6 +21,11 @@
${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}
+ % if GlobalStaff().has_user(user):
+
+ ${_("Maintenance")}
+
+ % endif
${_("Sign Out")}
diff --git a/cms/urls.py b/cms/urls.py
index 58503f9ed92f..50781b4bb3b5 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -276,6 +276,8 @@
certificates_list_handler, name='certificates_list_handler')
]
+# Maintenance Dashboard
+urlpatterns.append(path('maintenance/', include('cms.djangoapps.maintenance.urls', namespace='maintenance')))
if settings.DEBUG:
try:
diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss
index 90c5077c1f38..4d64e6768515 100644
--- a/lms/static/sass/_build-lms-v1.scss
+++ b/lms/static/sass/_build-lms-v1.scss
@@ -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';
diff --git a/lms/static/sass/features/_announcements.scss b/lms/static/sass/features/_announcements.scss
new file mode 100644
index 000000000000..0c3c01fe6077
--- /dev/null
+++ b/lms/static/sass/features/_announcements.scss
@@ -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;
+ }
+}
diff --git a/openedx/features/announcements/apps.py b/openedx/features/announcements/apps.py
new file mode 100644
index 000000000000..4bf964cae51b
--- /dev/null
+++ b/openedx/features/announcements/apps.py
@@ -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'},
+ }
+ }
+ }
diff --git a/openedx/features/announcements/forms.py b/openedx/features/announcements/forms.py
new file mode 100644
index 000000000000..879101ca37d0
--- /dev/null
+++ b/openedx/features/announcements/forms.py
@@ -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']
diff --git a/openedx/features/announcements/models.py b/openedx/features/announcements/models.py
new file mode 100644
index 000000000000..f58f61165db6
--- /dev/null
+++ b/openedx/features/announcements/models.py
@@ -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
diff --git a/openedx/features/announcements/settings/__init__.py b/openedx/features/announcements/settings/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/openedx/features/announcements/settings/common.py b/openedx/features/announcements/settings/common.py
new file mode 100644
index 000000000000..1a1a5ca497ab
--- /dev/null
+++ b/openedx/features/announcements/settings/common.py
@@ -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
diff --git a/openedx/features/announcements/settings/test.py b/openedx/features/announcements/settings/test.py
new file mode 100644
index 000000000000..47d57ca3dcbf
--- /dev/null
+++ b/openedx/features/announcements/settings/test.py
@@ -0,0 +1,8 @@
+"""Test settings for Announcements"""
+
+
+def plugin_settings(settings):
+ """
+ Test settings for Announcements
+ """
+ settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = True
diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.jsx
new file mode 100644
index 000000000000..9d370883352c
--- /dev/null
+++ b/openedx/features/announcements/static/announcements/jsx/Announcements.jsx
@@ -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 (
{'Skip to list of ' + this.state.count + ' announcements'}
);
+ }
+}
+
+// eslint-disable-next-line react/prefer-stateless-function
+class Announcement extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
+
+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) =>
+ );
+ if (this.state.has_prev) {
+ var prev_button = (
+
+
+ );
+ }
+ if (this.state.has_next) {
+ var next_button = (
+
+
+ );
+ }
+ return (
+
+ {children}
+ {prev_button}
+ {next_button}
+
+ );
+ }
+}
+
+export default class AnnouncementsView {
+ constructor() {
+ ReactDOM.render(
+
,
+ document.getElementById('announcements'),
+ );
+ ReactDOM.render(
+
,
+ document.getElementById('announcements-skip'),
+ );
+ }
+}
+
+export {AnnouncementsView, AnnouncementList, AnnouncementSkipLink};
diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx
new file mode 100644
index 000000000000..3ec55f392889
--- /dev/null
+++ b/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx
@@ -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(
+
,
+ );
+ component.root.instance.setState({count: 10});
+ const tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+
+ test('render test announcements', () => {
+ const component = renderer.create(
+
,
+ );
+ component.root.instance.setState(testAnnouncements);
+ const tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap
new file mode 100644
index 000000000000..bbf9bfaaaa69
--- /dev/null
+++ b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Announcements component render skip link 1`] = `
+
+ Skip to list of 10 announcements
+
+`;
+
+exports[`Announcements component render test announcements 1`] = `
+
+
+
Announcement 2",
+ }
+ }
+ />
+
+
+
+
+
+
+
+ 1 - 5) of 6
+
+
+
+`;
diff --git a/openedx/features/announcements/static/announcements/jsx/test-announcements.json b/openedx/features/announcements/static/announcements/jsx/test-announcements.json
new file mode 100644
index 000000000000..d23d39303020
--- /dev/null
+++ b/openedx/features/announcements/static/announcements/jsx/test-announcements.json
@@ -0,0 +1,17 @@
+{
+ "announcements": [
+ {"content": "Test Announcement 1"},
+ {"content": "Bold
Announcement 2"},
+ {"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
+}
diff --git a/openedx/features/announcements/tests/__init__.py b/openedx/features/announcements/tests/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/openedx/features/announcements/tests/test_announcements.py b/openedx/features/announcements/tests/test_announcements.py
new file mode 100644
index 000000000000..10c608b4a6cd
--- /dev/null
+++ b/openedx/features/announcements/tests/test_announcements.py
@@ -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),
+ ("
Formatted Announcement", True),
+ ("
Other Formatted Announcement", 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, '
Formatted Announcement")
diff --git a/openedx/features/announcements/urls.py b/openedx/features/announcements/urls.py
new file mode 100644
index 000000000000..0f0ad3a33960
--- /dev/null
+++ b/openedx/features/announcements/urls.py
@@ -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/', login_required(AnnouncementsJSONView.as_view()),
+ name='page',
+ ),
+]
diff --git a/openedx/features/announcements/views.py b/openedx/features/announcements/views.py
new file mode 100644
index 000000000000..b6657c29cc12
--- /dev/null
+++ b/openedx/features/announcements/views.py
@@ -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)
diff --git a/setup.py b/setup.py
index 3ccfe7734e33..3b8f8c59498d 100644
--- a/setup.py
+++ b/setup.py
@@ -138,6 +138,7 @@
],
"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 @@
"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",
diff --git a/webpack.common.config.js b/webpack.common.config.js
index c9b69eef4292..4eea3b5da9b6 100644
--- a/webpack.common.config.js
+++ b/webpack.common.config.js
@@ -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
// }