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:
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -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/",
|
||||
|
||||
@@ -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})
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
@import 'views/certificates';
|
||||
@import 'views/maintenance';
|
||||
|
||||
// +Base - Contexts
|
||||
// ====================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Test settings for Announcements"""
|
||||
|
||||
|
||||
def plugin_settings(settings):
|
||||
"""
|
||||
Test settings for Announcements
|
||||
"""
|
||||
settings.ENABLE_ANNOUNCEMENTS = True
|
||||
@@ -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};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>")
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
2
setup.py
2
setup.py
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user