From dc98398bcc1f3af993f8016a536a53fa6644681f Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 14 Jan 2025 15:37:48 -0500 Subject: [PATCH] Revert "fix: Remove pointless Maintenance and Announcement apps (#35852)" This reverts commit 9274852f2df9e64d3ee7b6de31227c3a5af4440b. --- .github/workflows/unit-test-shards.json | 1 + cms/djangoapps/maintenance/__init__.py | 0 cms/djangoapps/maintenance/tests.py | 311 ++++++++++++++++++ cms/djangoapps/maintenance/urls.py | 25 ++ cms/djangoapps/maintenance/views.py | 301 +++++++++++++++++ cms/envs/common.py | 2 + .../js/maintenance/force_publish_course.js | 83 +++++ cms/static/sass/_build-v1.scss | 1 + cms/static/sass/views/_maintenance.scss | 104 ++++++ .../maintenance/_announcement_delete.html | 40 +++ .../maintenance/_announcement_edit.html | 50 +++ .../maintenance/_announcement_index.html | 59 ++++ .../maintenance/_force_publish_course.html | 33 ++ cms/templates/maintenance/base.html | 21 ++ cms/templates/maintenance/container.html | 33 ++ cms/templates/maintenance/index.html | 20 ++ cms/templates/widgets/user_dropdown.html | 5 + cms/urls.py | 2 + lms/static/sass/_build-lms-v1.scss | 1 + lms/static/sass/features/_announcements.scss | 28 ++ openedx/features/announcements/apps.py | 32 ++ openedx/features/announcements/forms.py | 20 ++ openedx/features/announcements/models.py | 22 ++ .../announcements/settings/__init__.py | 0 .../features/announcements/settings/common.py | 21 ++ .../features/announcements/settings/test.py | 8 + .../announcements/jsx/Announcements.jsx | 141 ++++++++ .../announcements/jsx/Announcements.test.jsx | 25 ++ .../__snapshots__/Announcements.test.jsx.snap | 78 +++++ .../announcements/jsx/test-announcements.json | 17 + .../features/announcements/tests/__init__.py | 0 .../announcements/tests/test_announcements.py | 95 ++++++ openedx/features/announcements/urls.py | 13 + openedx/features/announcements/views.py | 37 +++ setup.py | 2 + webpack.common.config.js | 11 +- 36 files changed, 1637 insertions(+), 5 deletions(-) create mode 100644 cms/djangoapps/maintenance/__init__.py create mode 100644 cms/djangoapps/maintenance/tests.py create mode 100644 cms/djangoapps/maintenance/urls.py create mode 100644 cms/djangoapps/maintenance/views.py create mode 100644 cms/static/js/maintenance/force_publish_course.js create mode 100644 cms/static/sass/views/_maintenance.scss create mode 100644 cms/templates/maintenance/_announcement_delete.html create mode 100644 cms/templates/maintenance/_announcement_edit.html create mode 100644 cms/templates/maintenance/_announcement_index.html create mode 100644 cms/templates/maintenance/_force_publish_course.html create mode 100644 cms/templates/maintenance/base.html create mode 100644 cms/templates/maintenance/container.html create mode 100644 cms/templates/maintenance/index.html create mode 100644 lms/static/sass/features/_announcements.scss create mode 100644 openedx/features/announcements/apps.py create mode 100644 openedx/features/announcements/forms.py create mode 100644 openedx/features/announcements/models.py create mode 100644 openedx/features/announcements/settings/__init__.py create mode 100644 openedx/features/announcements/settings/common.py create mode 100644 openedx/features/announcements/settings/test.py create mode 100644 openedx/features/announcements/static/announcements/jsx/Announcements.jsx create mode 100644 openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx create mode 100644 openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap create mode 100644 openedx/features/announcements/static/announcements/jsx/test-announcements.json create mode 100644 openedx/features/announcements/tests/__init__.py create mode 100644 openedx/features/announcements/tests/test_announcements.py create mode 100644 openedx/features/announcements/urls.py create mode 100644 openedx/features/announcements/views.py diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 4709930493ce..e1e95242252d 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -239,6 +239,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/", diff --git a/cms/djangoapps/maintenance/__init__.py b/cms/djangoapps/maintenance/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py new file mode 100644 index 000000000000..a487f8e37faa --- /dev/null +++ b/cms/djangoapps/maintenance/tests.py @@ -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, '
') + + 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, '
') + 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}) diff --git a/cms/djangoapps/maintenance/urls.py b/cms/djangoapps/maintenance/urls.py new file mode 100644 index 000000000000..42febd139512 --- /dev/null +++ b/cms/djangoapps/maintenance/urls.py @@ -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\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'), + path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'), + re_path(r'^announcements/edit/(?P\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'), + path('announcements/delete/', AnnouncementDeleteView.as_view(), name='announcement_delete'), +] diff --git a/cms/djangoapps/maintenance/views.py b/cms/djangoapps/maintenance/views.py new file mode 100644 index 000000000000..357f64e90ebd --- /dev/null +++ b/cms/djangoapps/maintenance/views.py @@ -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' diff --git a/cms/envs/common.py b/cms/envs/common.py index 591247388a9d..61430e85bff3 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1702,6 +1702,8 @@ # New (Learning-Core-based) XBlock runtime 'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig', + # Maintenance tools + 'cms.djangoapps.maintenance', 'openedx.core.djangoapps.util.apps.UtilConfig', # Tracking diff --git a/cms/static/js/maintenance/force_publish_course.js b/cms/static/js/maintenance/force_publish_course.js new file mode 100644 index 000000000000..642b5bea4f2b --- /dev/null +++ b/cms/static/js/maintenance/force_publish_course.js @@ -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('
'), + error, + HtmlUtils.HTML('
') + ); + 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(); + }); + }); + }; +}); diff --git a/cms/static/sass/_build-v1.scss b/cms/static/sass/_build-v1.scss index 7aedd0c6b8a3..178f6b167473 100644 --- a/cms/static/sass/_build-v1.scss +++ b/cms/static/sass/_build-v1.scss @@ -77,6 +77,7 @@ @import 'views/group-configuration'; @import 'views/video-upload'; @import 'views/certificates'; +@import 'views/maintenance'; // +Base - Contexts // ==================== diff --git a/cms/static/sass/views/_maintenance.scss b/cms/static/sass/views/_maintenance.scss new file mode 100644 index 000000000000..58d1b7494751 --- /dev/null +++ b/cms/static/sass/views/_maintenance.scss @@ -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; + } + } + } +} diff --git a/cms/templates/maintenance/_announcement_delete.html b/cms/templates/maintenance/_announcement_delete.html new file mode 100644 index 000000000000..0397ef5a0bf8 --- /dev/null +++ b/cms/templates/maintenance/_announcement_delete.html @@ -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 name="viewtitle"> +

+ ${_('Delete Announcement')} +

+ + +<%block name="viewcontent"> +
+
+
+ +
+
+ +
+
+ ## xss-lint: disable=mako-invalid-html-filter + ${object.content | n} +
+
+
+ +
+
+
+
+ diff --git a/cms/templates/maintenance/_announcement_edit.html b/cms/templates/maintenance/_announcement_edit.html new file mode 100644 index 000000000000..a9bee1c6fce2 --- /dev/null +++ b/cms/templates/maintenance/_announcement_edit.html @@ -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 name="viewtitle"> +

+ ${_('Edit Announcement')} +

+ + +<%block name="viewcontent"> +
+
+
+
+ +
+ ## xss-lint: disable=mako-invalid-html-filter + ${form.as_p() | n} +
+
+ +
+
+
+
+
+ + +<%block name="header_extras"> + + + diff --git a/cms/templates/maintenance/_announcement_index.html b/cms/templates/maintenance/_announcement_index.html new file mode 100644 index 000000000000..68713c9986cc --- /dev/null +++ b/cms/templates/maintenance/_announcement_index.html @@ -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 + +%> +
+
+
+ % for announcement in announcement_list: +
+
+ ## xss-lint: disable=mako-invalid-html-filter + ${announcement.content | n} +
+ +
+ + % if announcement.active: + Active
+
+ % endfor +
+
+ + + + % if is_paginated: + % if page_obj.has_previous(): + + + + % endif + + % if page_obj.has_next(): + + + + % endif + % endif +
+
+
diff --git a/cms/templates/maintenance/_force_publish_course.html b/cms/templates/maintenance/_force_publish_course.html new file mode 100644 index 000000000000..31cc1e8887dc --- /dev/null +++ b/cms/templates/maintenance/_force_publish_course.html @@ -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 +%> +
+
+ +
+
+ ${_("Required data to force publish course.")} +
+
+ + +
${_('course-v1:edX+DemoX+Demo_Course')}
+
+
+
+
+
+
+ + + +
+
+
+
+
diff --git a/cms/templates/maintenance/base.html b/cms/templates/maintenance/base.html new file mode 100644 index 000000000000..6979797a629c --- /dev/null +++ b/cms/templates/maintenance/base.html @@ -0,0 +1,21 @@ +<%page expression_filter="h"/> +<%inherit file="../base.html" /> +<%def name='online_help_token()'><% return 'maintenance' %> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.urls import reverse +from django.utils.translation import gettext as _ +%> +<%block name="content"> +
+
+

+ + ${_('Maintenance Dashboard')} + +

+ <%block name="viewtitle"> + +
+<%block name="viewcontent"> + diff --git a/cms/templates/maintenance/container.html b/cms/templates/maintenance/container.html new file mode 100644 index 000000000000..417471a1bd66 --- /dev/null +++ b/cms/templates/maintenance/container.html @@ -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 name="viewtitle"> +

+ ${view['name']} +

+ + +<%block name="viewcontent"> +
+ <%include file="_${view['slug']}.html"/> +
+ + +<%block name="header_extras"> +% for template_name in ["force-published-course-response"]: + +% endfor + + +<%block name="requirejs"> + require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) { + MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}"); + }); + diff --git a/cms/templates/maintenance/index.html b/cms/templates/maintenance/index.html new file mode 100644 index 000000000000..293cb90b4a9c --- /dev/null +++ b/cms/templates/maintenance/index.html @@ -0,0 +1,20 @@ +<%page expression_filter="h"/> +<%inherit file="base.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import gettext as _ +from django.urls import reverse +%> +<%block name="title">${_('Maintenance Dashboard')} +<%block name="viewcontent"> +
+
    + % for view in views.values(): +
  • + ${view['name']} + ${view['description']} +
  • + % endfor +
+
+ diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html index 3fc0934b0db7..0ec00257ffe1 100644 --- a/cms/templates/widgets/user_dropdown.html +++ b/cms/templates/widgets/user_dropdown.html @@ -21,6 +21,11 @@

+ % if GlobalStaff().has_user(user): + + % endif diff --git a/cms/urls.py b/cms/urls.py index 2e64d4bbeb79..d72189445883 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -276,6 +276,8 @@ certificates_list_handler, name='certificates_list_handler') ] +# Maintenance Dashboard +urlpatterns.append(path('maintenance/', include('cms.djangoapps.maintenance.urls', namespace='maintenance'))) if settings.DEBUG: try: diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 7a77eb34ca07..1171e3d14cdf 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -68,6 +68,7 @@ // features @import 'features/bookmarks-v1'; +@import "features/announcements"; @import 'features/learner-profile'; @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; diff --git a/lms/static/sass/features/_announcements.scss b/lms/static/sass/features/_announcements.scss new file mode 100644 index 000000000000..0c3c01fe6077 --- /dev/null +++ b/lms/static/sass/features/_announcements.scss @@ -0,0 +1,28 @@ +// lms - features - announcements +// ==================== +.announcements-list { + display: inline-block; + width: 100%; + + .announcement { + background-color: $course-profile-bg; + align-content: center; + text-align: center; + padding: 22px 33px; + margin-bottom: 15px; + } + + .announcement-button { + display: inline-block; + padding: 3px 10px; + font-size: 0.75rem; + } + + .prev { + float: left; + } + + .next { + float: right; + } +} diff --git a/openedx/features/announcements/apps.py b/openedx/features/announcements/apps.py new file mode 100644 index 000000000000..4bf964cae51b --- /dev/null +++ b/openedx/features/announcements/apps.py @@ -0,0 +1,32 @@ +""" +Announcements Application Configuration +""" + + +from django.apps import AppConfig +from edx_django_utils.plugins import PluginURLs, PluginSettings + +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class AnnouncementsConfig(AppConfig): + """ + Application Configuration for Announcements + """ + name = 'openedx.features.announcements' + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: 'announcements', + PluginURLs.REGEX: '^announcements/', + PluginURLs.RELATIVE_PATH: 'urls', + } + }, + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'}, + SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'}, + } + } + } diff --git a/openedx/features/announcements/forms.py b/openedx/features/announcements/forms.py new file mode 100644 index 000000000000..879101ca37d0 --- /dev/null +++ b/openedx/features/announcements/forms.py @@ -0,0 +1,20 @@ +""" +Forms for the Announcement Editor +""" + + +from django import forms + +from .models import Announcement + + +class AnnouncementForm(forms.ModelForm): + """ + Form for editing Announcements + """ + content = forms.CharField(widget=forms.Textarea, label='', required=False) + active = forms.BooleanField(initial=True, required=False) + + class Meta: + model = Announcement + fields = ['content', 'active'] diff --git a/openedx/features/announcements/models.py b/openedx/features/announcements/models.py new file mode 100644 index 000000000000..f58f61165db6 --- /dev/null +++ b/openedx/features/announcements/models.py @@ -0,0 +1,22 @@ +""" +Models for Announcements +""" + + +from django.db import models + + +class Announcement(models.Model): + """ + Site-wide announcements to be displayed on the dashboard + + .. no_pii: + """ + class Meta: + app_label = 'announcements' + + content = models.CharField(max_length=1000, null=False, default="lorem ipsum") + active = models.BooleanField(default=True) + + def __str__(self): + return self.content diff --git a/openedx/features/announcements/settings/__init__.py b/openedx/features/announcements/settings/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/announcements/settings/common.py b/openedx/features/announcements/settings/common.py new file mode 100644 index 000000000000..1a1a5ca497ab --- /dev/null +++ b/openedx/features/announcements/settings/common.py @@ -0,0 +1,21 @@ +"""Common settings for Announcements""" + + +def plugin_settings(settings): + """ + Common settings for Announcements + .. toggle_name: FEATURES['ENABLE_ANNOUNCEMENTS'] + .. toggle_implementation: SettingDictToggle + .. toggle_default: False + .. toggle_description: This feature can be enabled to show system wide announcements + on the sidebar of the learner dashboard. Announcements can be created by Global Staff + users on maintenance dashboard of studio. Maintenance dashboard can accessed at + https://{studio.domain}/maintenance + .. toggle_warning: TinyMCE is needed to show an editor in the studio. + .. toggle_use_cases: open_edx + .. toggle_creation_date: 2017-11-08 + .. toggle_tickets: https://github.com/openedx/edx-platform/pull/16496 + """ + settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = False + # Configure number of announcements to show per page + settings.FEATURES['ANNOUNCEMENTS_PER_PAGE'] = 5 diff --git a/openedx/features/announcements/settings/test.py b/openedx/features/announcements/settings/test.py new file mode 100644 index 000000000000..47d57ca3dcbf --- /dev/null +++ b/openedx/features/announcements/settings/test.py @@ -0,0 +1,8 @@ +"""Test settings for Announcements""" + + +def plugin_settings(settings): + """ + Test settings for Announcements + """ + settings.FEATURES['ENABLE_ANNOUNCEMENTS'] = True diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.jsx new file mode 100644 index 000000000000..9d370883352c --- /dev/null +++ b/openedx/features/announcements/static/announcements/jsx/Announcements.jsx @@ -0,0 +1,141 @@ +// eslint-disable-next-line max-classes-per-file +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import {Button} from '@edx/paragon'; +import $ from 'jquery'; + +class AnnouncementSkipLink extends React.Component { + constructor(props) { + super(props); + this.state = { + count: 0 + }; + $.get('/announcements/page/1') + .then(data => { + this.setState({ + count: data.count + }); + }); + } + + render() { + return (
{'Skip to list of ' + this.state.count + ' announcements'}
); + } +} + +// eslint-disable-next-line react/prefer-stateless-function +class Announcement extends React.Component { + render() { + return ( +
+ ); + } +} + +Announcement.propTypes = { + content: PropTypes.string.isRequired, +}; + +class AnnouncementList extends React.Component { + constructor(props) { + super(props); + this.state = { + page: 1, + announcements: [], + // eslint-disable-next-line react/no-unused-state + num_pages: 0, + has_prev: false, + has_next: false, + start_index: 0, + end_index: 0, + }; + } + + retrievePage(page) { + $.get('/announcements/page/' + page) + .then(data => { + this.setState({ + announcements: data.announcements, + has_next: data.next, + has_prev: data.prev, + // eslint-disable-next-line react/no-unused-state + num_pages: data.num_pages, + count: data.count, + start_index: data.start_index, + end_index: data.end_index, + page: page + }); + }); + } + + renderPrevPage() { + this.retrievePage(this.state.page - 1); + } + + renderNextPage() { + this.retrievePage(this.state.page + 1); + } + + // eslint-disable-next-line react/no-deprecated, react/sort-comp + componentWillMount() { + this.retrievePage(this.state.page); + } + + render() { + var children = this.state.announcements.map( + // eslint-disable-next-line react/no-array-index-key + (announcement, index) => + ); + if (this.state.has_prev) { + var prev_button = ( +
+
+ ); + } + if (this.state.has_next) { + var next_button = ( +
+
+ ); + } + return ( +
+ {children} + {prev_button} + {next_button} +
+ ); + } +} + +export default class AnnouncementsView { + constructor() { + ReactDOM.render( + , + document.getElementById('announcements'), + ); + ReactDOM.render( + , + document.getElementById('announcements-skip'), + ); + } +} + +export {AnnouncementsView, AnnouncementList, AnnouncementSkipLink}; diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx new file mode 100644 index 000000000000..3ec55f392889 --- /dev/null +++ b/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import testAnnouncements from './test-announcements.json'; + +import {AnnouncementSkipLink, AnnouncementList} from './Announcements'; + +describe('Announcements component', () => { + test('render skip link', () => { + const component = renderer.create( + , + ); + component.root.instance.setState({count: 10}); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + test('render test announcements', () => { + const component = renderer.create( + , + ); + component.root.instance.setState(testAnnouncements); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap new file mode 100644 index 000000000000..bbf9bfaaaa69 --- /dev/null +++ b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Announcements component render skip link 1`] = ` +
+ Skip to list of 10 announcements +
+`; + +exports[`Announcements component render test announcements 1`] = ` +
+
+
Announcement 2", + } + } + /> +
+
+
+
+
+ + + 1 - 5) of 6 + +
+
+`; diff --git a/openedx/features/announcements/static/announcements/jsx/test-announcements.json b/openedx/features/announcements/static/announcements/jsx/test-announcements.json new file mode 100644 index 000000000000..d23d39303020 --- /dev/null +++ b/openedx/features/announcements/static/announcements/jsx/test-announcements.json @@ -0,0 +1,17 @@ +{ + "announcements": [ + {"content": "Test Announcement 1"}, + {"content": "Bold Announcement 2"}, + {"content": "Test Announcement 3"}, + {"content": "Test Announcement 4"}, + {"content": "Test Announcement 5"}, + {"content": "Test Announcement 6"} + ], + "has_next": true, + "has_prev": false, + "num_pages": 2, + "count": 6, + "start_index": 1, + "end_index": 5, + "page": 1 +} diff --git a/openedx/features/announcements/tests/__init__.py b/openedx/features/announcements/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/announcements/tests/test_announcements.py b/openedx/features/announcements/tests/test_announcements.py new file mode 100644 index 000000000000..10c608b4a6cd --- /dev/null +++ b/openedx/features/announcements/tests/test_announcements.py @@ -0,0 +1,95 @@ +""" +Unit tests for the announcements feature. +""" + +import json +from unittest.mock import patch + +from django.conf import settings +from django.test import TestCase +from django.test.client import Client +from django.urls import reverse + +from common.djangoapps.student.tests.factories import AdminFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.features.announcements.models import Announcement + +TEST_ANNOUNCEMENTS = [ + ("Active Announcement", True), + ("Inactive Announcement", False), + ("Another Test Announcement", True), + ("Formatted Announcement", True), + ("Other Formatted Announcement", True), +] + + +@skip_unless_lms +class TestGlobalAnnouncements(TestCase): + """ + Test Announcements in LMS + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + Announcement.objects.bulk_create([ + Announcement(content=content, active=active) + for content, active in TEST_ANNOUNCEMENTS + ]) + + def setUp(self): + super().setUp() + self.client = Client() + self.admin = AdminFactory.create( + email='staff@edx.org', + username='admin', + password='pass' + ) + self.client.login(username=self.admin.username, password='pass') + + @patch.dict(settings.FEATURES, {'ENABLE_ANNOUNCEMENTS': False}) + def test_feature_flag_disabled(self): + """Ensures that the default settings effectively disables the feature""" + response = self.client.get('/dashboard') + self.assertNotContains(response, 'AnnouncementsView') + self.assertNotContains(response, '
Formatted Announcement") diff --git a/openedx/features/announcements/urls.py b/openedx/features/announcements/urls.py new file mode 100644 index 000000000000..0f0ad3a33960 --- /dev/null +++ b/openedx/features/announcements/urls.py @@ -0,0 +1,13 @@ +""" +Defines URLs for announcements in the LMS. +""" +from django.contrib.auth.decorators import login_required +from django.urls import path + +from .views import AnnouncementsJSONView + +urlpatterns = [ + path('page/', login_required(AnnouncementsJSONView.as_view()), + name='page', + ), +] diff --git a/openedx/features/announcements/views.py b/openedx/features/announcements/views.py new file mode 100644 index 000000000000..b6657c29cc12 --- /dev/null +++ b/openedx/features/announcements/views.py @@ -0,0 +1,37 @@ +""" +Views to show announcements. +""" + + +from django.conf import settings +from django.http import JsonResponse +from django.views.generic.list import ListView + +from .models import Announcement + + +class AnnouncementsJSONView(ListView): + """ + View returning a page of announcements for the dashboard + """ + model = Announcement + object_list = Announcement.objects.filter(active=True) + paginate_by = settings.FEATURES.get('ANNOUNCEMENTS_PER_PAGE', 5) + + def get(self, request, *args, **kwargs): + """ + Return active announcements as json + """ + context = self.get_context_data() + + announcements = [{"content": announcement.content} for announcement in context['object_list']] + result = { + "announcements": announcements, + "next": context['page_obj'].has_next(), + "prev": context['page_obj'].has_previous(), + "start_index": context['page_obj'].start_index(), + "end_index": context['page_obj'].end_index(), + "count": context['paginator'].count, + "num_pages": context['paginator'].num_pages, + } + return JsonResponse(result) diff --git a/setup.py b/setup.py index 3ccfe7734e33..3b8f8c59498d 100644 --- a/setup.py +++ b/setup.py @@ -138,6 +138,7 @@ ], "lms.djangoapp": [ "ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig", + "announcements = openedx.features.announcements.apps:AnnouncementsConfig", "content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig", "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", "course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig", @@ -156,6 +157,7 @@ "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", ], "cms.djangoapp": [ + "announcements = openedx.features.announcements.apps:AnnouncementsConfig", "ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig", "bookmarks = openedx.core.djangoapps.bookmarks.apps:BookmarksConfig", "course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig", diff --git a/webpack.common.config.js b/webpack.common.config.js index c9b69eef4292..4eea3b5da9b6 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -133,6 +133,7 @@ module.exports = Merge.smart({ CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js', Currency: './openedx/features/course_experience/static/course_experience/js/currency.js', + AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx', CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx', // Common @@ -193,19 +194,19 @@ module.exports = Merge.smart({ multiple: [ { search: defineHeader, replace: '' }, { search: defineFooter, replace: '' }, - { + { search: /(\/\* RequireJS) \*\//g, replace(match, p1, offset, string) { return p1; } }, - { + { search: /\/\* Webpack/g, replace(match, p1, offset, string) { return match + ' */'; } }, - { + { search: /text!(.*?\.underscore)/g, replace(match, p1, offset, string) { return p1; @@ -656,13 +657,13 @@ module.exports = Merge.smart({ // We used to have node: { fs: 'empty' } in this file, // that is no longer supported. Adding this based on the recommendation in // https://stackoverflow.com/questions/64361940/webpack-error-configuration-node-has-an-unknown-property-fs - // + // // With this uncommented tests fail // Tests failed in the following suites: // * lms javascript // * xmodule-webpack javascript // Error: define cannot be used indirect - // + // // fallback: { // fs: false // }