Revert "fix: Remove pointless Maintenance and Announcement apps (#35852)"
This reverts commit 9274852f2d.
This commit is contained in:
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -238,6 +238,7 @@
|
||||
"cms/djangoapps/cms_user_tasks/",
|
||||
"cms/djangoapps/course_creators/",
|
||||
"cms/djangoapps/export_course_metadata/",
|
||||
"cms/djangoapps/maintenance/",
|
||||
"cms/djangoapps/models/",
|
||||
"cms/djangoapps/pipeline_js/",
|
||||
"cms/djangoapps/xblock_config/",
|
||||
|
||||
0
cms/djangoapps/maintenance/__init__.py
Normal file
0
cms/djangoapps/maintenance/__init__.py
Normal file
311
cms/djangoapps/maintenance/tests.py
Normal file
311
cms/djangoapps/maintenance/tests.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Tests for the maintenance app views.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
|
||||
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
|
||||
from openedx.features.announcements.models import Announcement
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from .views import COURSE_KEY_ERROR_MESSAGES, MAINTENANCE_VIEWS
|
||||
|
||||
# This list contains URLs of all maintenance app views.
|
||||
MAINTENANCE_URLS = [reverse(view['url']) for view in MAINTENANCE_VIEWS.values()]
|
||||
|
||||
|
||||
class TestMaintenanceIndex(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for maintenance index view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = AdminFactory()
|
||||
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
|
||||
self.assertTrue(login_success)
|
||||
self.view_url = reverse('maintenance:maintenance_index')
|
||||
|
||||
def test_maintenance_index(self):
|
||||
"""
|
||||
Test that maintenance index view lists all the maintenance app views.
|
||||
"""
|
||||
response = self.client.get(self.view_url)
|
||||
self.assertContains(response, 'Maintenance', status_code=200)
|
||||
|
||||
# Check that all the expected links appear on the index page.
|
||||
for url in MAINTENANCE_URLS:
|
||||
self.assertContains(response, url, status_code=200)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MaintenanceViewTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Base class for maintenance view tests.
|
||||
"""
|
||||
view_url = ''
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = AdminFactory()
|
||||
login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
|
||||
self.assertTrue(login_success)
|
||||
|
||||
def verify_error_message(self, data, error_message):
|
||||
"""
|
||||
Verify the response contains error message.
|
||||
"""
|
||||
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertContains(response, error_message, status_code=200)
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Reverse the setup.
|
||||
"""
|
||||
self.client.logout()
|
||||
super().tearDown()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class MaintenanceViewAccessTests(MaintenanceViewTestCase):
|
||||
"""
|
||||
Tests for access control of maintenance views.
|
||||
"""
|
||||
@ddt.data(*MAINTENANCE_URLS)
|
||||
def test_require_login(self, url):
|
||||
"""
|
||||
Test that maintenance app requires user login.
|
||||
"""
|
||||
# Log out then try to retrieve the page
|
||||
self.client.logout()
|
||||
response = self.client.get(url)
|
||||
|
||||
# Expect a redirect to the login page
|
||||
redirect_url = '{login_url}?next={original_url}'.format(
|
||||
login_url=settings.LOGIN_URL,
|
||||
original_url=url,
|
||||
)
|
||||
|
||||
# Studio login redirects to LMS login
|
||||
self.assertRedirects(response, redirect_url, target_status_code=302)
|
||||
|
||||
@ddt.data(*MAINTENANCE_URLS)
|
||||
def test_global_staff_access(self, url):
|
||||
"""
|
||||
Test that all maintenance app views are accessible to global staff user.
|
||||
"""
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ddt.data(*MAINTENANCE_URLS)
|
||||
def test_non_global_staff_access(self, url):
|
||||
"""
|
||||
Test that all maintenance app views are not accessible to non-global-staff user.
|
||||
"""
|
||||
user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD)
|
||||
login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD)
|
||||
self.assertTrue(login_success)
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertContains(
|
||||
response,
|
||||
f'Must be {settings.PLATFORM_NAME} staff to perform this action.',
|
||||
status_code=403
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestForcePublish(MaintenanceViewTestCase):
|
||||
"""
|
||||
Tests for the force publish view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.view_url = reverse('maintenance:force_publish_course')
|
||||
|
||||
def setup_test_course(self):
|
||||
"""
|
||||
Creates the course and add some changes to it.
|
||||
|
||||
Returns:
|
||||
course: a course object
|
||||
"""
|
||||
course = CourseFactory.create()
|
||||
# Add some changes to course
|
||||
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
|
||||
self.store.create_child(
|
||||
self.user.id,
|
||||
chapter.location,
|
||||
'html',
|
||||
block_id='html_component'
|
||||
)
|
||||
# verify that course has changes.
|
||||
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
|
||||
return course
|
||||
|
||||
@ddt.data(
|
||||
('', COURSE_KEY_ERROR_MESSAGES['empty_course_key']),
|
||||
('edx', COURSE_KEY_ERROR_MESSAGES['invalid_course_key']),
|
||||
('course-v1:e+d+X', COURSE_KEY_ERROR_MESSAGES['course_key_not_found']),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_invalid_course_key_messages(self, course_key, error_message):
|
||||
"""
|
||||
Test all error messages for invalid course keys.
|
||||
"""
|
||||
# validate that course key contains error message
|
||||
self.verify_error_message(
|
||||
data={'course-id': course_key},
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
def test_already_published(self):
|
||||
"""
|
||||
Test that when a course is forcefully publish, we get a 'course is already published' message.
|
||||
"""
|
||||
course = self.setup_test_course()
|
||||
|
||||
# publish the course
|
||||
source_store = modulestore()._get_modulestore_for_courselike(course.id) # pylint: disable=protected-access
|
||||
source_store.force_publish_course(course.id, self.user.id, commit=True)
|
||||
|
||||
# now course is published, we should get `already published course` error.
|
||||
self.verify_error_message(
|
||||
data={'course-id': str(course.id)},
|
||||
error_message='Course is already in published state.'
|
||||
)
|
||||
|
||||
def verify_versions_are_different(self, course):
|
||||
"""
|
||||
Verify draft and published versions point to different locations.
|
||||
|
||||
Arguments:
|
||||
course (object): a course object.
|
||||
"""
|
||||
# get draft and publish branch versions
|
||||
versions = get_course_versions(str(course.id))
|
||||
|
||||
# verify that draft and publish point to different versions
|
||||
self.assertNotEqual(versions['draft-branch'], versions['published-branch'])
|
||||
|
||||
def get_force_publish_course_response(self, course):
|
||||
"""
|
||||
Get force publish the course response.
|
||||
|
||||
Arguments:
|
||||
course (object): a course object.
|
||||
|
||||
Returns:
|
||||
response : response from force publish post view.
|
||||
"""
|
||||
# Verify versions point to different locations initially
|
||||
self.verify_versions_are_different(course)
|
||||
|
||||
# force publish course view
|
||||
data = {
|
||||
'course-id': str(course.id)
|
||||
}
|
||||
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
return response_data
|
||||
|
||||
def test_force_publish_dry_run(self):
|
||||
"""
|
||||
Test that dry run does not publishes the course but shows possible outcome if force published is executed.
|
||||
"""
|
||||
course = self.setup_test_course()
|
||||
response = self.get_force_publish_course_response(course)
|
||||
|
||||
self.assertIn('current_versions', response)
|
||||
|
||||
# verify that course still has changes as we just dry ran force publish course.
|
||||
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
|
||||
|
||||
# verify that both branch versions are still different
|
||||
self.verify_versions_are_different(course)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestAnnouncementsViews(MaintenanceViewTestCase):
|
||||
"""
|
||||
Tests for the announcements edit view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.admin = AdminFactory.create(
|
||||
email='staff@edx.org',
|
||||
username='admin',
|
||||
password=self.TEST_PASSWORD
|
||||
)
|
||||
self.client.login(username=self.admin.username, password=self.TEST_PASSWORD)
|
||||
self.non_staff_user = UserFactory.create(
|
||||
email='test@edx.org',
|
||||
username='test',
|
||||
password=self.TEST_PASSWORD
|
||||
)
|
||||
|
||||
def test_index(self):
|
||||
"""
|
||||
Test create announcement view
|
||||
"""
|
||||
url = reverse("maintenance:announcement_index")
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, '<div class="announcement-container">')
|
||||
|
||||
def test_create(self):
|
||||
"""
|
||||
Test create announcement view
|
||||
"""
|
||||
url = reverse("maintenance:announcement_create")
|
||||
self.client.post(url, {"content": "Test Create Announcement", "active": True})
|
||||
result = Announcement.objects.filter(content="Test Create Announcement").exists()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_edit(self):
|
||||
"""
|
||||
Test edit announcement view
|
||||
"""
|
||||
announcement = Announcement.objects.create(content="test")
|
||||
announcement.save()
|
||||
url = reverse("maintenance:announcement_edit", kwargs={"pk": announcement.pk})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, '<div class="wrapper-form announcement-container">')
|
||||
self.client.post(url, {"content": "Test Edit Announcement", "active": True})
|
||||
announcement = Announcement.objects.get(pk=announcement.pk)
|
||||
self.assertEqual(announcement.content, "Test Edit Announcement")
|
||||
|
||||
def test_delete(self):
|
||||
"""
|
||||
Test delete announcement view
|
||||
"""
|
||||
announcement = Announcement.objects.create(content="Test Delete")
|
||||
announcement.save()
|
||||
url = reverse("maintenance:announcement_delete", kwargs={"pk": announcement.pk})
|
||||
self.client.post(url)
|
||||
result = Announcement.objects.filter(content="Test Edit Announcement").exists()
|
||||
self.assertFalse(result)
|
||||
|
||||
def _test_403(self, viewname, kwargs=None):
|
||||
url = reverse("maintenance:%s" % viewname, kwargs=kwargs)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_authorization(self):
|
||||
self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD)
|
||||
announcement = Announcement.objects.create(content="Test Delete")
|
||||
announcement.save()
|
||||
|
||||
self._test_403("announcement_index")
|
||||
self._test_403("announcement_create")
|
||||
self._test_403("announcement_edit", {"pk": announcement.pk})
|
||||
self._test_403("announcement_delete", {"pk": announcement.pk})
|
||||
25
cms/djangoapps/maintenance/urls.py
Normal file
25
cms/djangoapps/maintenance/urls.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
URLs for the maintenance app.
|
||||
"""
|
||||
|
||||
from django.urls import path, re_path
|
||||
|
||||
from .views import (
|
||||
AnnouncementCreateView,
|
||||
AnnouncementDeleteView,
|
||||
AnnouncementEditView,
|
||||
AnnouncementIndexView,
|
||||
ForcePublishCourseView,
|
||||
MaintenanceIndexView
|
||||
)
|
||||
|
||||
app_name = 'cms.djangoapps.maintenance'
|
||||
|
||||
urlpatterns = [
|
||||
path('', MaintenanceIndexView.as_view(), name='maintenance_index'),
|
||||
re_path(r'^force_publish_course/?$', ForcePublishCourseView.as_view(), name='force_publish_course'),
|
||||
re_path(r'^announcements/(?P<page>\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'),
|
||||
path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'),
|
||||
re_path(r'^announcements/edit/(?P<pk>\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'),
|
||||
path('announcements/delete/<int:pk>', AnnouncementDeleteView.as_view(), name='announcement_delete'),
|
||||
]
|
||||
301
cms/djangoapps/maintenance/views.py
Normal file
301
cms/djangoapps/maintenance/views.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Views for the maintenance app.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import transaction
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from common.djangoapps.util.views import require_global_staff
|
||||
from openedx.features.announcements.forms import AnnouncementForm
|
||||
from openedx.features.announcements.models import Announcement
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# This dict maintains all the views that will be used Maintenance app.
|
||||
MAINTENANCE_VIEWS = {
|
||||
'force_publish_course': {
|
||||
'url': 'maintenance:force_publish_course',
|
||||
'name': _('Force Publish Course'),
|
||||
'slug': 'force_publish_course',
|
||||
'description': _(
|
||||
'Sometimes the draft and published branches of a course can get out of sync. Force publish course command '
|
||||
'resets the published branch of a course to point to the draft branch, effectively force publishing the '
|
||||
'course. This view dry runs the force publish command'
|
||||
),
|
||||
},
|
||||
'announcement_index': {
|
||||
'url': 'maintenance:announcement_index',
|
||||
'name': _('Edit Announcements'),
|
||||
'slug': 'announcement_index',
|
||||
'description': _(
|
||||
'This view shows the announcement editor to create or alter announcements that are shown on the right'
|
||||
'side of the dashboard.'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
COURSE_KEY_ERROR_MESSAGES = {
|
||||
'empty_course_key': _('Please provide course id.'),
|
||||
'invalid_course_key': _('Invalid course key.'),
|
||||
'course_key_not_found': _('No matching course found.')
|
||||
}
|
||||
|
||||
|
||||
class MaintenanceIndexView(View):
|
||||
"""
|
||||
Index view for maintenance dashboard, used by global staff.
|
||||
|
||||
This view lists some commands/tasks that can be used to dry run or execute directly.
|
||||
"""
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request):
|
||||
"""Render the maintenance index view. """
|
||||
return render_to_response('maintenance/index.html', {
|
||||
'views': MAINTENANCE_VIEWS,
|
||||
})
|
||||
|
||||
|
||||
class MaintenanceBaseView(View):
|
||||
"""
|
||||
Base class for Maintenance views.
|
||||
"""
|
||||
|
||||
template = 'maintenance/container.html'
|
||||
|
||||
def __init__(self, view=None):
|
||||
super().__init__()
|
||||
self.context = {
|
||||
'view': view if view else '',
|
||||
'form_data': {},
|
||||
'error': False,
|
||||
'msg': ''
|
||||
}
|
||||
|
||||
def render_response(self):
|
||||
"""
|
||||
A short method to render_to_response that renders response.
|
||||
"""
|
||||
if self.request.headers.get('x-requested-with') == 'XMLHttpRequest':
|
||||
return JsonResponse(self.context)
|
||||
return render_to_response(self.template, self.context)
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request):
|
||||
"""
|
||||
Render get view.
|
||||
"""
|
||||
return self.render_response()
|
||||
|
||||
def validate_course_key(self, course_key, branch=ModuleStoreEnum.BranchName.draft):
|
||||
"""
|
||||
Validates the course_key that would be used by maintenance app views.
|
||||
|
||||
Arguments:
|
||||
course_key (string): a course key
|
||||
branch: a course locator branch, default value is ModuleStoreEnum.BranchName.draft .
|
||||
values can be either ModuleStoreEnum.BranchName.draft or ModuleStoreEnum.BranchName.published.
|
||||
|
||||
Returns:
|
||||
course_usage_key (CourseLocator): course usage locator
|
||||
"""
|
||||
if not course_key:
|
||||
raise ValidationError(COURSE_KEY_ERROR_MESSAGES['empty_course_key'])
|
||||
|
||||
course_usage_key = CourseKey.from_string(course_key)
|
||||
|
||||
if not modulestore().has_course(course_usage_key):
|
||||
raise ItemNotFoundError(COURSE_KEY_ERROR_MESSAGES['course_key_not_found'])
|
||||
|
||||
# get branch specific locator
|
||||
course_usage_key = course_usage_key.for_branch(branch)
|
||||
|
||||
return course_usage_key
|
||||
|
||||
|
||||
class ForcePublishCourseView(MaintenanceBaseView):
|
||||
"""
|
||||
View for force publishing state of the course, used by the global staff.
|
||||
|
||||
This view uses `force_publish_course` method of modulestore which publishes the draft state of the course. After
|
||||
the course has been forced published, both draft and publish draft point to same location.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(MAINTENANCE_VIEWS['force_publish_course'])
|
||||
self.context.update({
|
||||
'current_versions': [],
|
||||
'updated_versions': [],
|
||||
'form_data': {
|
||||
'course_id': '',
|
||||
'is_dry_run': True
|
||||
}
|
||||
})
|
||||
|
||||
def get_course_branch_versions(self, versions):
|
||||
"""
|
||||
Returns a dict containing unicoded values of draft and published draft versions.
|
||||
"""
|
||||
return {
|
||||
'draft-branch': str(versions['draft-branch']),
|
||||
'published-branch': str(versions['published-branch'])
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
@method_decorator(require_global_staff)
|
||||
def post(self, request):
|
||||
"""
|
||||
This method force publishes a course if dry-run argument is not selected. If dry-run is selected, this view
|
||||
shows possible outcome if the `force_publish_course` modulestore method is executed.
|
||||
|
||||
Arguments:
|
||||
course_id (string): a request parameter containing course id
|
||||
is_dry_run (string): a request parameter containing dry run value.
|
||||
It is obtained from checkbox so it has either values 'on' or ''.
|
||||
"""
|
||||
course_id = request.POST.get('course-id')
|
||||
|
||||
self.context.update({
|
||||
'form_data': {
|
||||
'course_id': course_id
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
course_usage_key = self.validate_course_key(course_id)
|
||||
except InvalidKeyError:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = COURSE_KEY_ERROR_MESSAGES['invalid_course_key']
|
||||
except ItemNotFoundError as exc:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = str(exc)
|
||||
except ValidationError as exc:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = str(exc)
|
||||
|
||||
if self.context['error']:
|
||||
return self.render_response()
|
||||
|
||||
source_store = modulestore()._get_modulestore_for_courselike(course_usage_key) # pylint: disable=protected-access
|
||||
if not hasattr(source_store, 'force_publish_course'):
|
||||
self.context['msg'] = _('Force publishing course is not supported with old mongo courses.')
|
||||
log.warning(
|
||||
'Force publishing course is not supported with old mongo courses. \
|
||||
%s attempted to force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
current_versions = self.get_course_branch_versions(get_course_versions(course_id))
|
||||
|
||||
# if publish and draft are NOT different
|
||||
if current_versions['published-branch'] == current_versions['draft-branch']:
|
||||
self.context['msg'] = _('Course is already in published state.')
|
||||
log.warning(
|
||||
'Course is already in published state. %s attempted to force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
self.context['current_versions'] = current_versions
|
||||
log.info(
|
||||
'%s dry ran force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
|
||||
class AnnouncementBaseView(View):
|
||||
"""
|
||||
Base view for Announcements pages
|
||||
"""
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AnnouncementIndexView(ListView, MaintenanceBaseView):
|
||||
"""
|
||||
View for viewing the announcements shown on the dashboard, used by the global staff.
|
||||
"""
|
||||
model = Announcement
|
||||
object_list = Announcement.objects.order_by('-active')
|
||||
context_object_name = 'announcement_list'
|
||||
paginate_by = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(MAINTENANCE_VIEWS['announcement_index'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['view'] = MAINTENANCE_VIEWS['announcement_index']
|
||||
return context
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data()
|
||||
return render_to_response(self.template, context)
|
||||
|
||||
|
||||
class AnnouncementEditView(UpdateView, AnnouncementBaseView):
|
||||
"""
|
||||
View for editing an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
form_class = AnnouncementForm
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_edit.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['action_url'] = reverse('maintenance:announcement_edit', kwargs={'pk': context['announcement'].pk})
|
||||
return context
|
||||
|
||||
|
||||
class AnnouncementCreateView(CreateView, AnnouncementBaseView):
|
||||
"""
|
||||
View for creating an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
form_class = AnnouncementForm
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_edit.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['action_url'] = reverse('maintenance:announcement_create')
|
||||
return context
|
||||
|
||||
|
||||
class AnnouncementDeleteView(DeleteView, AnnouncementBaseView):
|
||||
"""
|
||||
View for deleting an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_delete.html'
|
||||
@@ -1699,6 +1699,8 @@ INSTALLED_APPS = [
|
||||
# New (Learning-Core-based) XBlock runtime
|
||||
'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig',
|
||||
|
||||
# Maintenance tools
|
||||
'cms.djangoapps.maintenance',
|
||||
'openedx.core.djangoapps.util.apps.UtilConfig',
|
||||
|
||||
# Tracking
|
||||
|
||||
83
cms/static/js/maintenance/force_publish_course.js
Normal file
83
cms/static/js/maintenance/force_publish_course.js
Normal file
@@ -0,0 +1,83 @@
|
||||
define([ // jshint ignore:line
|
||||
'jquery',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'common/js/components/utils/view_utils',
|
||||
'edx-ui-toolkit/js/utils/string-utils',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
],
|
||||
function($, _, gettext, ViewUtils, StringUtils, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return function(maintenanceViewURL) {
|
||||
var showError;
|
||||
// Reset values
|
||||
$('#reset-button').click(function(e) {
|
||||
e.preventDefault();
|
||||
$('#course-id').val('');
|
||||
$('#dry-run').prop('checked', true);
|
||||
// clear out result container
|
||||
$('#result-container').html('');
|
||||
});
|
||||
|
||||
showError = function(containerElSelector, error) {
|
||||
var errorWrapperElSelector, errorHtml;
|
||||
errorWrapperElSelector = containerElSelector + ' .wrapper-error';
|
||||
errorHtml = HtmlUtils.joinHtml(
|
||||
HtmlUtils.HTML('<div class="error" aria-live="polite" id="course-id-error">'),
|
||||
error,
|
||||
HtmlUtils.HTML('</div>')
|
||||
);
|
||||
HtmlUtils.setHtml($(errorWrapperElSelector), HtmlUtils.HTML(errorHtml));
|
||||
$(errorWrapperElSelector).css('display', 'inline-block');
|
||||
$(errorWrapperElSelector).fadeOut(5000);
|
||||
};
|
||||
|
||||
$('form#force_publish').submit(function(event) {
|
||||
var attrs, forcePublishedTemplate, $submitButton, deferred, promise, data;
|
||||
event.preventDefault();
|
||||
|
||||
// clear out result container
|
||||
$('#result-container').html('');
|
||||
|
||||
$submitButton = $('#submit_force_publish');
|
||||
deferred = new $.Deferred();
|
||||
promise = deferred.promise();
|
||||
|
||||
data = $('#force_publish').serialize();
|
||||
|
||||
// disable submit button while executing.
|
||||
ViewUtils.disableElementWhileRunning($submitButton, function() { return promise; });
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: maintenanceViewURL,
|
||||
dataType: 'json',
|
||||
data: data
|
||||
})
|
||||
.done(function(response) {
|
||||
if (response.error) {
|
||||
showError('#course-id-container', response.msg);
|
||||
} else {
|
||||
if (response.msg) {
|
||||
showError('#result-error', response.msg);
|
||||
} else {
|
||||
attrs = $.extend({}, response, {StringUtils: StringUtils});
|
||||
forcePublishedTemplate = HtmlUtils.template(
|
||||
$('#force-published-course-response-tpl').text()
|
||||
);
|
||||
HtmlUtils.setHtml($('#result-container'), forcePublishedTemplate(attrs));
|
||||
}
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
// response.responseText here because it would show some strange output, it may output Traceback
|
||||
// sometimes if unexpected issue arises. Better to show just internal error when getting 500 error.
|
||||
showError('#result-error', gettext('Internal Server Error.'));
|
||||
})
|
||||
.always(function() {
|
||||
deferred.resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -77,6 +77,7 @@
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
@import 'views/certificates';
|
||||
@import 'views/maintenance';
|
||||
|
||||
// +Base - Contexts
|
||||
// ====================
|
||||
|
||||
104
cms/static/sass/views/_maintenance.scss
Normal file
104
cms/static/sass/views/_maintenance.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
.maintenance-header {
|
||||
text-align: center;
|
||||
margin-top: 50px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-content {
|
||||
padding: 3rem 0;
|
||||
|
||||
.maintenance-list {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
|
||||
.view-list-container {
|
||||
padding: 10px 15px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
display: inline-block;
|
||||
width: 20%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.view-desc {
|
||||
display: inline-block;
|
||||
width: 80%;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-form {
|
||||
width: 60%;
|
||||
margin: auto;
|
||||
|
||||
.result-list {
|
||||
height: calc(100vh - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.result {
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
margin-top: 15px;
|
||||
padding: 15px 30px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
line-height: 9px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-radio div {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
div.error {
|
||||
color: #f00;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
div.head-output {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
div.main-output {
|
||||
color: #0a0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
.announcement-item {
|
||||
display: inline-block;
|
||||
max-width: 300px;
|
||||
min-width: 300px;
|
||||
margin: 15px;
|
||||
|
||||
.announcement-content {
|
||||
background-color: $body-bg;
|
||||
text-align: center;
|
||||
padding: 22px 33px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
cms/templates/maintenance/_announcement_delete.html
Normal file
40
cms/templates/maintenance/_announcement_delete.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<%block name="title">${_('Delete Announcement')}</%block>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${_('Delete Announcement')}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<div id="delete-announcement-form" class="studio-view maintenance-form">
|
||||
<div class="container-message wrapper-message">
|
||||
<div class="message has-warnings" aria-hidden="true">
|
||||
<p class="warning">
|
||||
<span class="icon fa fa-warning"></span>
|
||||
${_('Are you sure you want to delete this Announcement?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action="" method="post" class="form-create announcement-container">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="announcement-item">
|
||||
<div class="announcement-content">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${object.content | n}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="action action-primary" value="Confirm">${_('Confirm')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</%block>
|
||||
50
cms/templates/maintenance/_announcement_edit.html
Normal file
50
cms/templates/maintenance/_announcement_edit.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<%block name="title">${_('Edit Announcement')}</%block>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${_('Edit Announcement')}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<div class="container studio-view maintenance-form">
|
||||
<form class="form-create" method="post" action="${action_url}">
|
||||
<div class="wrapper-form announcement-container">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="announcement-item">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${form.as_p() | n}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="action action-primary">${_('Save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<script src="${STATIC_URL}js/vendor/tinymce/js/tinymce/tinymce.full.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
tinymce.init({
|
||||
selector: "#id_content",
|
||||
height: 450,
|
||||
skin: "studio-tmce5",
|
||||
branding: false,
|
||||
plugins: [
|
||||
"advlist autolink lists link image charmap print anchor",
|
||||
"searchreplace visualblocks code",
|
||||
"insertdatetime media table contextmenu paste"
|
||||
],
|
||||
toolbar: "undo redo styleselect bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent link image"
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
59
cms/templates/maintenance/_announcement_index.html
Normal file
59
cms/templates/maintenance/_announcement_index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
%>
|
||||
<div class="studio-view maintenance-form">
|
||||
<div class="form-create">
|
||||
<div class="announcement-container">
|
||||
% for announcement in announcement_list:
|
||||
<div class="announcement-item">
|
||||
<div class="announcement-content">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${announcement.content | n}
|
||||
</div>
|
||||
|
||||
<div class="actions-list">
|
||||
<span>
|
||||
% if announcement.active:
|
||||
Active <span class="icon fa fa-check-square-o" aria-hidden="true" />
|
||||
% else:
|
||||
Inactive <span class="icon fa fa-square-o" aria-hidden="true" />
|
||||
% endif
|
||||
</span>
|
||||
<a class="action-item announcement-edit"
|
||||
href="${ reverse('maintenance:announcement_edit', kwargs={'pk': announcement.pk}) }">
|
||||
<button class="btn-default">Edit</button>
|
||||
</a>
|
||||
<a class="action-item announcement-delete"
|
||||
href="${ reverse('maintenance:announcement_delete', kwargs={'pk': announcement.pk}) }">
|
||||
<button class="btn-default">Delete</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
% endfor
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="${ reverse('maintenance:announcement_create') }">
|
||||
<button class="action action-primary">${_('Create New')}</button>
|
||||
</a>
|
||||
% if is_paginated:
|
||||
% if page_obj.has_previous():
|
||||
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.previous_page_number()}) }">
|
||||
<button class="action action-secondary">${_('previous')}</button>
|
||||
</a>
|
||||
% endif
|
||||
|
||||
% if page_obj.has_next():
|
||||
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.next_page_number()}) }">
|
||||
<button class="action action-secondary">${_('next')}</button>
|
||||
</a>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
33
cms/templates/maintenance/_force_publish_course.html
Normal file
33
cms/templates/maintenance/_force_publish_course.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<div id="force-published-form" class="wrap-instructor-info studio-view maintenance-form">
|
||||
<form id="force_publish" class="form-create" method="post">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="wrapper-form">
|
||||
<fieldset>
|
||||
<legend class="sr">${_("Required data to force publish course.")}</legend>
|
||||
<div class="list-input">
|
||||
<div id="course-id-container" class="field text required">
|
||||
<label for="course-id">${_('Course ID')}</label>
|
||||
<input id="course-id" type="text" name="course-id" aria-describedby="course-id-desc" required />
|
||||
<div id="course-id-desc" class="tip tip-stacked">${_('course-v1:edX+DemoX+Demo_Course')}</div>
|
||||
<div class="wrapper-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" id="submit_force_publish" class="action action-primary">${_('Force Publish Course')}
|
||||
</button>
|
||||
<button id="reset-button" class="action action-secondary action-cancel"
|
||||
aria-describedby="reset-values-desc">${_('Reset')}</button>
|
||||
<span id="reset-values-desc" class="is-hidden">${_('Reset values')}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result-error"><div class="wrapper-error"></div></div>
|
||||
<div id="result-container" class="result-container"></div>
|
||||
</div>
|
||||
21
cms/templates/maintenance/base.html
Normal file
21
cms/templates/maintenance/base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="../base.html" />
|
||||
<%def name='online_help_token()'><% return 'maintenance' %></%def>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
%>
|
||||
<%block name="content">
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="maintenance-header">
|
||||
<h2>
|
||||
<a href="${reverse('maintenance:maintenance_index')}">
|
||||
<span>${_('Maintenance Dashboard')}</span>
|
||||
</a>
|
||||
</h2>
|
||||
<%block name="viewtitle">
|
||||
</%block>
|
||||
</div>
|
||||
<%block name="viewcontent"></%block>
|
||||
</%block>
|
||||
33
cms/templates/maintenance/container.html
Normal file
33
cms/templates/maintenance/container.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
%>
|
||||
<%block name="title">${view['name']}</%block>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${view['name']}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<%include file="_${view['slug']}.html"/>
|
||||
</section>
|
||||
</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["force-published-course-response"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/maintenance/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) {
|
||||
MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}");
|
||||
});
|
||||
</%block>
|
||||
20
cms/templates/maintenance/index.html
Normal file
20
cms/templates/maintenance/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import reverse
|
||||
%>
|
||||
<%block name="title">${_('Maintenance Dashboard')}</%block>
|
||||
<%block name="viewcontent">
|
||||
<div class="container maintenance-content">
|
||||
<ul class="maintenance-list">
|
||||
% for view in views.values():
|
||||
<li class="view-list-container">
|
||||
<a class="view-name" href='${reverse(view["url"])}'>${view['name']}</a>
|
||||
<span class="view-desc">${view['description']}</span>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -21,6 +21,11 @@
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
% if GlobalStaff().has_user(user):
|
||||
<li class="nav-item">
|
||||
<a href="${reverse('maintenance:maintenance_index')}">${_("Maintenance")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-account-signout">
|
||||
<a class="action action-signout" href="${settings.FRONTEND_LOGOUT_URL}">${_("Sign Out")}</a>
|
||||
</li>
|
||||
|
||||
@@ -276,6 +276,8 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'):
|
||||
certificates_list_handler, name='certificates_list_handler')
|
||||
]
|
||||
|
||||
# Maintenance Dashboard
|
||||
urlpatterns.append(path('maintenance/', include('cms.djangoapps.maintenance.urls', namespace='maintenance')))
|
||||
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
|
||||
@@ -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';
|
||||
|
||||
28
lms/static/sass/features/_announcements.scss
Normal file
28
lms/static/sass/features/_announcements.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
// lms - features - announcements
|
||||
// ====================
|
||||
.announcements-list {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
.announcement {
|
||||
background-color: $course-profile-bg;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
padding: 22px 33px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.announcement-button {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.prev {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.next {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
32
openedx/features/announcements/apps.py
Normal file
32
openedx/features/announcements/apps.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Announcements Application Configuration
|
||||
"""
|
||||
|
||||
|
||||
from django.apps import AppConfig
|
||||
from edx_django_utils.plugins import PluginURLs, PluginSettings
|
||||
|
||||
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
|
||||
|
||||
|
||||
class AnnouncementsConfig(AppConfig):
|
||||
"""
|
||||
Application Configuration for Announcements
|
||||
"""
|
||||
name = 'openedx.features.announcements'
|
||||
|
||||
plugin_app = {
|
||||
PluginURLs.CONFIG: {
|
||||
ProjectType.LMS: {
|
||||
PluginURLs.NAMESPACE: 'announcements',
|
||||
PluginURLs.REGEX: '^announcements/',
|
||||
PluginURLs.RELATIVE_PATH: 'urls',
|
||||
}
|
||||
},
|
||||
PluginSettings.CONFIG: {
|
||||
ProjectType.LMS: {
|
||||
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'},
|
||||
SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'},
|
||||
}
|
||||
}
|
||||
}
|
||||
20
openedx/features/announcements/forms.py
Normal file
20
openedx/features/announcements/forms.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Forms for the Announcement Editor
|
||||
"""
|
||||
|
||||
|
||||
from django import forms
|
||||
|
||||
from .models import Announcement
|
||||
|
||||
|
||||
class AnnouncementForm(forms.ModelForm):
|
||||
"""
|
||||
Form for editing Announcements
|
||||
"""
|
||||
content = forms.CharField(widget=forms.Textarea, label='', required=False)
|
||||
active = forms.BooleanField(initial=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Announcement
|
||||
fields = ['content', 'active']
|
||||
22
openedx/features/announcements/models.py
Normal file
22
openedx/features/announcements/models.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Models for Announcements
|
||||
"""
|
||||
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Announcement(models.Model):
|
||||
"""
|
||||
Site-wide announcements to be displayed on the dashboard
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
class Meta:
|
||||
app_label = 'announcements'
|
||||
|
||||
content = models.CharField(max_length=1000, null=False, default="lorem ipsum")
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.content
|
||||
0
openedx/features/announcements/settings/__init__.py
Normal file
0
openedx/features/announcements/settings/__init__.py
Normal file
21
openedx/features/announcements/settings/common.py
Normal file
21
openedx/features/announcements/settings/common.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Common settings for Announcements"""
|
||||
|
||||
|
||||
def plugin_settings(settings):
|
||||
"""
|
||||
Common settings for Announcements
|
||||
.. toggle_name: FEATURES['ENABLE_ANNOUNCEMENTS']
|
||||
.. toggle_implementation: SettingDictToggle
|
||||
.. toggle_default: False
|
||||
.. toggle_description: This feature can be enabled to show system wide announcements
|
||||
on the sidebar of the learner dashboard. Announcements can be created by Global Staff
|
||||
users on maintenance dashboard of studio. Maintenance dashboard can accessed at
|
||||
https://{studio.domain}/maintenance
|
||||
.. toggle_warning: TinyMCE is needed to show an editor in the studio.
|
||||
.. toggle_use_cases: open_edx
|
||||
.. toggle_creation_date: 2017-11-08
|
||||
.. toggle_tickets: https://github.com/openedx/edx-platform/pull/16496
|
||||
"""
|
||||
settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = False
|
||||
# Configure number of announcements to show per page
|
||||
settings.FEATURES['ANNOUNCEMENTS_PER_PAGE'] = 5
|
||||
8
openedx/features/announcements/settings/test.py
Normal file
8
openedx/features/announcements/settings/test.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Test settings for Announcements"""
|
||||
|
||||
|
||||
def plugin_settings(settings):
|
||||
"""
|
||||
Test settings for Announcements
|
||||
"""
|
||||
settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = True
|
||||
@@ -0,0 +1,141 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button} from '@edx/paragon';
|
||||
import $ from 'jquery';
|
||||
|
||||
class AnnouncementSkipLink extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
count: 0
|
||||
};
|
||||
$.get('/announcements/page/1')
|
||||
.then(data => {
|
||||
this.setState({
|
||||
count: data.count
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div>{'Skip to list of ' + this.state.count + ' announcements'}</div>);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
class Announcement extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="announcement"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{__html: this.props.content}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Announcement.propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
class AnnouncementList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
page: 1,
|
||||
announcements: [],
|
||||
// eslint-disable-next-line react/no-unused-state
|
||||
num_pages: 0,
|
||||
has_prev: false,
|
||||
has_next: false,
|
||||
start_index: 0,
|
||||
end_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
retrievePage(page) {
|
||||
$.get('/announcements/page/' + page)
|
||||
.then(data => {
|
||||
this.setState({
|
||||
announcements: data.announcements,
|
||||
has_next: data.next,
|
||||
has_prev: data.prev,
|
||||
// eslint-disable-next-line react/no-unused-state
|
||||
num_pages: data.num_pages,
|
||||
count: data.count,
|
||||
start_index: data.start_index,
|
||||
end_index: data.end_index,
|
||||
page: page
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderPrevPage() {
|
||||
this.retrievePage(this.state.page - 1);
|
||||
}
|
||||
|
||||
renderNextPage() {
|
||||
this.retrievePage(this.state.page + 1);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/no-deprecated, react/sort-comp
|
||||
componentWillMount() {
|
||||
this.retrievePage(this.state.page);
|
||||
}
|
||||
|
||||
render() {
|
||||
var children = this.state.announcements.map(
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
(announcement, index) => <Announcement key={index} content={announcement.content} />
|
||||
);
|
||||
if (this.state.has_prev) {
|
||||
var prev_button = (
|
||||
<div>
|
||||
<Button
|
||||
className={['announcement-button', 'prev']}
|
||||
onClick={() => this.renderPrevPage()}
|
||||
label="← previous"
|
||||
/>
|
||||
<span className="sr-only">{this.state.start_index + ' - ' + this.state.end_index + ') of ' + this.state.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (this.state.has_next) {
|
||||
var next_button = (
|
||||
<div>
|
||||
<Button
|
||||
className={['announcement-button', 'next']}
|
||||
onClick={() => this.renderNextPage()}
|
||||
label="next →"
|
||||
/>
|
||||
<span className="sr-only">{this.state.start_index + ' - ' + this.state.end_index + ') of ' + this.state.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="announcements-list">
|
||||
{children}
|
||||
{prev_button}
|
||||
{next_button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class AnnouncementsView {
|
||||
constructor() {
|
||||
ReactDOM.render(
|
||||
<AnnouncementList />,
|
||||
document.getElementById('announcements'),
|
||||
);
|
||||
ReactDOM.render(
|
||||
<AnnouncementSkipLink />,
|
||||
document.getElementById('announcements-skip'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export {AnnouncementsView, AnnouncementList, AnnouncementSkipLink};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import testAnnouncements from './test-announcements.json';
|
||||
|
||||
import {AnnouncementSkipLink, AnnouncementList} from './Announcements';
|
||||
|
||||
describe('Announcements component', () => {
|
||||
test('render skip link', () => {
|
||||
const component = renderer.create(
|
||||
<AnnouncementSkipLink />,
|
||||
);
|
||||
component.root.instance.setState({count: 10});
|
||||
const tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('render test announcements', () => {
|
||||
const component = renderer.create(
|
||||
<AnnouncementList />,
|
||||
);
|
||||
component.root.instance.setState(testAnnouncements);
|
||||
const tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Announcements component render skip link 1`] = `
|
||||
<div>
|
||||
Skip to list of 10 announcements
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Announcements component render test announcements 1`] = `
|
||||
<div
|
||||
className="announcements-list"
|
||||
>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Test Announcement 1",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Bold <b>Announcement 2</b>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Test Announcement 3",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Test Announcement 4",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Test Announcement 5",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="announcement"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "Test Announcement 6",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
className="announcement-button next btn"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
next →
|
||||
</button>
|
||||
<span
|
||||
className="sr-only"
|
||||
>
|
||||
1 - 5) of 6
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"announcements": [
|
||||
{"content": "Test Announcement 1"},
|
||||
{"content": "Bold <b>Announcement 2</b>"},
|
||||
{"content": "Test Announcement 3"},
|
||||
{"content": "Test Announcement 4"},
|
||||
{"content": "Test Announcement 5"},
|
||||
{"content": "Test Announcement 6"}
|
||||
],
|
||||
"has_next": true,
|
||||
"has_prev": false,
|
||||
"num_pages": 2,
|
||||
"count": 6,
|
||||
"start_index": 1,
|
||||
"end_index": 5,
|
||||
"page": 1
|
||||
}
|
||||
0
openedx/features/announcements/tests/__init__.py
Normal file
0
openedx/features/announcements/tests/__init__.py
Normal file
95
openedx/features/announcements/tests/test_announcements.py
Normal file
95
openedx/features/announcements/tests/test_announcements.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unit tests for the announcements feature.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from common.djangoapps.student.tests.factories import AdminFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from openedx.features.announcements.models import Announcement
|
||||
|
||||
TEST_ANNOUNCEMENTS = [
|
||||
("Active Announcement", True),
|
||||
("Inactive Announcement", False),
|
||||
("Another Test Announcement", True),
|
||||
("<strong>Formatted Announcement</strong>", True),
|
||||
("<a>Other Formatted Announcement</a>", True),
|
||||
]
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestGlobalAnnouncements(TestCase):
|
||||
"""
|
||||
Test Announcements in LMS
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
Announcement.objects.bulk_create([
|
||||
Announcement(content=content, active=active)
|
||||
for content, active in TEST_ANNOUNCEMENTS
|
||||
])
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = Client()
|
||||
self.admin = AdminFactory.create(
|
||||
email='staff@edx.org',
|
||||
username='admin',
|
||||
password='pass'
|
||||
)
|
||||
self.client.login(username=self.admin.username, password='pass')
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_ANNOUNCEMENTS': False})
|
||||
def test_feature_flag_disabled(self):
|
||||
"""Ensures that the default settings effectively disables the feature"""
|
||||
response = self.client.get('/dashboard')
|
||||
self.assertNotContains(response, 'AnnouncementsView')
|
||||
self.assertNotContains(response, '<div id="announcements"')
|
||||
|
||||
def test_feature_flag_enabled(self):
|
||||
"""Ensures that enabling the flag, enables the feature"""
|
||||
response = self.client.get('/dashboard')
|
||||
self.assertContains(response, 'AnnouncementsView')
|
||||
|
||||
def test_pagination(self):
|
||||
url = reverse("announcements:page", kwargs={"page": 1})
|
||||
response = self.client.get(url)
|
||||
data = json.loads(response.content.decode('utf-8'))
|
||||
assert data['num_pages'] == 1
|
||||
## double the number of announcements to verify the number of pages increases
|
||||
self.setUpTestData()
|
||||
response = self.client.get(url)
|
||||
data = json.loads(response.content.decode('utf-8'))
|
||||
assert data['num_pages'] == 2
|
||||
|
||||
def test_active(self):
|
||||
"""
|
||||
Ensures that active announcements are visible on the dashboard
|
||||
"""
|
||||
url = reverse("announcements:page", kwargs={"page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Active Announcement")
|
||||
|
||||
def test_inactive(self):
|
||||
"""
|
||||
Ensures that inactive announcements aren't visible on the dashboard
|
||||
"""
|
||||
url = reverse("announcements:page", kwargs={"page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, "Inactive Announcement")
|
||||
|
||||
def test_formatted(self):
|
||||
"""
|
||||
Ensures that formatting in announcements is rendered properly
|
||||
"""
|
||||
url = reverse("announcements:page", kwargs={"page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "<strong>Formatted Announcement</strong>")
|
||||
13
openedx/features/announcements/urls.py
Normal file
13
openedx/features/announcements/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Defines URLs for announcements in the LMS.
|
||||
"""
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
|
||||
from .views import AnnouncementsJSONView
|
||||
|
||||
urlpatterns = [
|
||||
path('page/<int:page>', login_required(AnnouncementsJSONView.as_view()),
|
||||
name='page',
|
||||
),
|
||||
]
|
||||
37
openedx/features/announcements/views.py
Normal file
37
openedx/features/announcements/views.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Views to show announcements.
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
from .models import Announcement
|
||||
|
||||
|
||||
class AnnouncementsJSONView(ListView):
|
||||
"""
|
||||
View returning a page of announcements for the dashboard
|
||||
"""
|
||||
model = Announcement
|
||||
object_list = Announcement.objects.filter(active=True)
|
||||
paginate_by = settings.FEATURES.get('ANNOUNCEMENTS_PER_PAGE', 5)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return active announcements as json
|
||||
"""
|
||||
context = self.get_context_data()
|
||||
|
||||
announcements = [{"content": announcement.content} for announcement in context['object_list']]
|
||||
result = {
|
||||
"announcements": announcements,
|
||||
"next": context['page_obj'].has_next(),
|
||||
"prev": context['page_obj'].has_previous(),
|
||||
"start_index": context['page_obj'].start_index(),
|
||||
"end_index": context['page_obj'].end_index(),
|
||||
"count": context['paginator'].count,
|
||||
"num_pages": context['paginator'].num_pages,
|
||||
}
|
||||
return JsonResponse(result)
|
||||
2
setup.py
2
setup.py
@@ -138,6 +138,7 @@ setup(
|
||||
],
|
||||
"lms.djangoapp": [
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",
|
||||
"content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig",
|
||||
"course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig",
|
||||
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
|
||||
@@ -156,6 +157,7 @@ setup(
|
||||
"program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig",
|
||||
],
|
||||
"cms.djangoapp": [
|
||||
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
"bookmarks = openedx.core.djangoapps.bookmarks.apps:BookmarksConfig",
|
||||
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user