diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 0c98248874..d0e88f0092 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -35,7 +35,7 @@ from common.djangoapps.student.roles import ( from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.views import is_privileged_user from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, @@ -385,7 +385,7 @@ def get_course(request, course_key, check_tab=True): 'site_key': settings.RECAPTCHA_SITE_KEY, }, "is_email_verified": request.user.is_active, - "only_verified_users_can_post": False, + "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key), } diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 2f457db5fe..dce16d0d32 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -20,7 +20,7 @@ from pytz import UTC from rest_framework import status from rest_framework.test import APIClient, APITestCase -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -1064,6 +1064,7 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi assert vertical_keys == expected_non_courseware_keys +@ddt.ddt @httpretty.activate @disable_signal(api, 'thread_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @@ -1139,6 +1140,41 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): response_data = json.loads(response.content.decode('utf-8')) assert response_data == expected_response_data + @ddt.data( + (False, False, status.HTTP_200_OK), + (False, True, status.HTTP_400_BAD_REQUEST), + (True, False, status.HTTP_200_OK), + (True, True, status.HTTP_200_OK), + ) + @ddt.unpack + def test_creation_for_non_verified_user(self, email_verified, only_verified_user_can_post, response_status): + """ + Tests posts cannot be created if ONLY_VERIFIED_USERS_CAN_POST is enabled and user email is unverified. + """ + with override_waffle_flag(ONLY_VERIFIED_USERS_CAN_POST, only_verified_user_can_post): + self.user.is_active = email_verified + self.user.save() + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread({ + "id": "test_thread", + "username": self.user.username, + "read": True, + }) + self.register_post_thread_response(cs_thread) + request_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "# Test \n This is a very long body but will not be truncated for the preview.", + } + response = self.client.post( + self.url, + json.dumps(request_data), + content_type="application/json" + ) + assert response.status_code == response_status + @httpretty.activate @disable_signal(api, 'thread_deleted') @@ -2019,6 +2055,7 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): assert response.status_code == 404 +@ddt.ddt @httpretty.activate @disable_signal(api, 'comment_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @@ -2134,6 +2171,69 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): ) assert response.status_code == 403 + @ddt.data( + (False, False, status.HTTP_200_OK), + (False, True, status.HTTP_400_BAD_REQUEST), + (True, False, status.HTTP_200_OK), + (True, True, status.HTTP_200_OK), + ) + @ddt.unpack + def test_creation_for_non_verified_user(self, email_verified, only_verified_user_can_post, response_status): + """ + Tests comments/replies cannot be created if ONLY_VERIFIED_USERS_CAN_POST is enabled and + user email is unverified. + """ + with override_waffle_flag(ONLY_VERIFIED_USERS_CAN_POST, only_verified_user_can_post): + self.user.is_active = email_verified + self.user.save() + self.register_get_user_response(self.user) + self.register_thread() + self.register_comment() + request_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + expected_response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "

Test body

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response = self.client.post( + self.url, + json.dumps(request_data), + content_type="application/json" + ) + assert response.status_code == response_status + @httpretty.activate @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 3c5e920418..c5c20cde0f 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -29,6 +29,7 @@ from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user +from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role @@ -671,13 +672,19 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): """ if not request.data.get("course_id"): raise ValidationError({"course_id": ["This field is required."]}) - if is_captcha_enabled(CourseKey.from_string(request.data.get("course_id"))): + course_key_str = request.data.get("course_id") + course_key = CourseKey.from_string(course_key_str) + if is_captcha_enabled(course_key): captcha_token = request.data.get('captcha_token') if not captcha_token: raise ValidationError({'captcha_token': 'This field is required.'}) if not verify_recaptcha_token(captcha_token): return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) + data = request.data.copy() data.pop('captcha_token', None) return Response(create_thread(request, data)) @@ -1037,14 +1044,20 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): """ if not request.data.get("thread_id"): raise ValidationError({"thread_id": ["This field is required."]}) - course_id = get_course_id_from_thread_id(request.data["thread_id"]) - if is_captcha_enabled(CourseKey.from_string(course_id)): + course_key_str = get_course_id_from_thread_id(request.data["thread_id"]) + course_key = CourseKey.from_string(course_key_str) + + if is_captcha_enabled(course_key): captcha_token = request.data.get('captcha_token') if not captcha_token: raise ValidationError({'captcha_token': 'This field is required.'}) if not verify_recaptcha_token(captcha_token): return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) + data = request.data.copy() data.pop('captcha_token', None) return Response(create_comment(request, data)) diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a01a3b6a0a..6965f462f9 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -15,3 +15,15 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag( f"{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe", __name__ ) + + +# .. toggle_name: discussions.only_verified_users_can_post +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to allow only verified users to post in discussions +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2025-22-07 +# .. toggle_target_removal_date: 2026-04-01 +ONLY_VERIFIED_USERS_CAN_POST = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.only_verified_users_can_post", __name__ +)