From 0c493b6ec2eeedb815fc280763c859b9b26106a6 Mon Sep 17 00:00:00 2001 From: Pandi Ganesh Date: Tue, 17 Jun 2025 08:50:00 +0000 Subject: [PATCH] feat: add Studio API for bulk enable/disable discussions for a course Implemented Studio API for bulk enable/disable discussions for a course. --- .../test_bulk_enabledisable_discussions.py | 117 ++++++++++++++++++ cms/djangoapps/contentstore/views/course.py | 58 ++++++++- cms/urls.py | 3 + 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py diff --git a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py new file mode 100644 index 0000000000..e9ed344f9d --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py @@ -0,0 +1,117 @@ +""" +Test the enable/disable discussions for all units API endpoint. +""" +import json + +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient +from common.djangoapps.student.tests.factories import UserFactory + + +class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase): + """ + Test the enable/disable discussions for all units API endpoint. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True, is_superuser=True) + self.user.set_password(self.user_password) + self.user.save() + + self.course_key = CourseKey.from_string("course-v1:edx+TestX+2025") + + self.url = reverse('bulk_enable_disable_discussions', args=[str(self.course_key)]) + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=self.user_password) + + # Create a test course + self.course = CourseFactory.create( + org=self.course_key.org, + course=self.course_key.course, + run=self.course_key.run, + default_store=ModuleStoreEnum.Type.split, + display_name="EnableDisableDiscussionsTestCase Course", + ) + with self.store.bulk_operations(self.course_key): + section = BlockFactory.create( + parent=self.course, + category='chapter', + display_name="Generated Section", + ) + sequence = BlockFactory.create( + parent=section, + category='sequential', + display_name="Generated Sequence", + ) + unit1 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section1", + discussion_enabled=True, + ) + unit2 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section2", + discussion_enabled=True, + ) + + def test_disable_discussions_for_all_units(self): + """ + Test that the API successfully disables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(False) + + def test_enable_discussions_for_all_units(self): + """ + Test that the API successfully enables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(True) + + def enable_disable_discussions_for_all_units(self, is_enabled): + """ + Test that the API successfully enables/disables discussions for all units. + """ + data = { + "discussion_enabled": is_enabled + } + response = self.client.put(self.url, data=json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + response_data = response.json() + print(response_data) + self.assertEqual(response_data['updated_and_republished'], 0 if is_enabled else 2) + + # Check that all verticals now have discussion_enabled set to the expected value + with self.store.bulk_operations(self.course_key): + verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + self.assertEqual(vertical.discussion_enabled, is_enabled) + + def test_permission_denied_for_non_staff(self): + """ + Test that non-staff users are denied access to the API. + """ + # Create a non-staff user + non_staff_user = UserFactory(is_staff=False, is_superuser=False) + non_staff_user.set_password(self.user_password) + non_staff_user.save() + + # Create a new client for the non-staff user + non_staff_client = AjaxEnabledTestClient() + non_staff_client.login(username=non_staff_user.username, password=self.user_password) + + response = non_staff_client.put(self.url, content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_badrequest_for_empty_request_body(self): + """ + Test that the API returns a 400 for an empty request body. + """ + response = self.client.put(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 400) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 81ad1eb6dd..2a75710be7 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -135,7 +135,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing', 'course_notifications_handler', 'textbooks_list_handler', 'textbooks_detail_handler', 'group_configurations_list_handler', 'group_configurations_detail_handler', - 'get_course_and_check_access'] + 'get_course_and_check_access', 'bulk_enable_disable_discussions'] class AccessListFallback(Exception): @@ -1710,6 +1710,62 @@ def group_configurations_detail_handler(request, course_key_string, group_config ) +@login_required +@expect_json +@ensure_csrf_cookie +@require_http_methods(["PUT"]) +def bulk_enable_disable_discussions(request, course_key_string): + """ + API endpoint to enable/disable discussions for all verticals in the course and republish them. + + PUT + json: enable/disable discussions for all units and republish + """ + try: + # Validate the course key + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return JsonResponseBadRequest({"error": "Invalid course key format"}) + + user = request.user + + # check that logged in user has permissions to update this course + if not has_studio_write_access(user, course_key): + raise PermissionDenied() + + if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): + return JsonResponseBadRequest({"error": "Only supports json requests"}) + + if 'discussion_enabled' not in request.json: + return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"}) + discussion_enabled = request.json['discussion_enabled'] + log.info( + "User %s is attempting to %s discussions for all verticals in course %s", + user.username, + "enable" if discussion_enabled else "disable", + course_key + ) + + if request.method == 'PUT': + try: + store = modulestore() + changed = 0 + with store.bulk_operations(course_key): + verticals = store.get_items(course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + if vertical.discussion_enabled != discussion_enabled: + vertical.discussion_enabled = discussion_enabled + store.update_item(vertical, user.id) + + if store.has_published_version(vertical): + store.publish(vertical.location, user.id) + changed += 1 + return JsonResponse({"updated_and_republished": changed}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + log.exception("Exception occurred while enabling/disabling discussion: %s", str(e)) + return JsonResponseBadRequest({"error": str(e)}) + + def are_content_experiments_enabled(course): """ Returns True if content experiments have been enabled for the course. diff --git a/cms/urls.py b/cms/urls.py index d01e89d9d2..f2c2c8b31a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -201,6 +201,9 @@ urlpatterns = oauth2_urlpatterns + [ path('accessibility', contentstore_views.accessibility, name='accessibility'), re_path(fr'api/youtube/courses/{COURSELIKE_KEY_PATTERN}/edx-video-ids$', contentstore_views.get_course_youtube_edx_videos_ids, name='youtube_edx_video_ids'), + re_path(fr'^api/courses/{settings.COURSE_KEY_PATTERN}/bulk_enable_disable_discussions$', + contentstore_views.bulk_enable_disable_discussions, + name='bulk_enable_disable_discussions'), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: