diff --git a/lms/djangoapps/discussion/config/settings.py b/lms/djangoapps/discussion/config/settings.py index 76e4ad7594..06040ad82e 100644 --- a/lms/djangoapps/discussion/config/settings.py +++ b/lms/djangoapps/discussion/config/settings.py @@ -3,6 +3,10 @@ Discussion settings. """ from django.conf import settings +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +WAFFLE_NAMESPACE = 'discussion' + # .. toggle_name: FEATURES['ENABLE_FORUM_DAILY_DIGEST'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -17,3 +21,13 @@ ENABLE_FORUM_DAILY_DIGEST = 'enable_forum_daily_digest' def is_forum_daily_digest_enabled(): """Returns whether forum notification features should be visible""" return settings.FEATURES.get('ENABLE_FORUM_DAILY_DIGEST', False) + +# .. toggle_name: discussion.enable_captcha +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable account level preferences for notifications +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2025-07-12 +# .. toggle_target_removal_date: 2025-07-29 +# .. toggle_warning: When the flag is ON, users will be able to see captcha for discussion. +ENABLE_CAPTCHA_IN_DISCUSSION = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_captcha', __name__) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 8d4a2dd112..836eea62e7 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -128,7 +128,7 @@ from .utils import ( get_usernames_from_search_string, set_attribute, is_posting_allowed, - can_user_notify_all_learners + can_user_notify_all_learners, is_captcha_enabled ) User = get_user_model() @@ -378,6 +378,11 @@ def get_course(request, course_key, check_tab=True): 'is_notify_all_learners_enabled': can_user_notify_all_learners( course_key, user_roles, is_course_staff, is_course_admin ), + 'captcha_settings': { + 'enabled': is_captcha_enabled(course_key), + 'site_key': settings.RECAPTCHA_SITE_KEY, + } + } diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 72e9426a65..a571e395a9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -214,7 +214,11 @@ class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase) 'edit_reasons': [{'code': 'test-edit-reason', 'label': 'Test Edit Reason'}], 'post_close_reasons': [{'code': 'test-close-reason', 'label': 'Test Close Reason'}], 'show_discussions': True, - 'is_notify_all_learners_enabled': False + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': '', + }, } @ddt.data( diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 3e11b1ba5f..0f12531516 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -557,7 +557,11 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], 'show_discussions': True, - 'is_notify_all_learners_enabled': False + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': '', + }, } ) diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index bddd81c3f0..6a08058ba5 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -4,12 +4,17 @@ Utils for discussion API. from datetime import datetime from typing import Dict, List +import requests +from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.paginator import Paginator from django.db.models.functions import Length from pytz import UTC from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread + +from lms.djangoapps.discussion.config.settings import ENABLE_CAPTCHA_IN_DISCUSSION from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFY_ALL_LEARNERS from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction @@ -402,3 +407,39 @@ def can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_cou ]) return is_staff_or_instructor and ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key) + + +def verify_recaptcha_token(token): + """ + Helper function to verify reCAPTCHA token + """ + verify_url = settings.RECAPTCHA_VERIFY_URL + verify_data = { + 'secret': settings.RECAPTCHA_PRIVATE_KEY, + 'response': token, + } + + try: + response = requests.post(verify_url, data=verify_data, timeout=10) + result = response.json() + return result.get('success', False) + except: # pylint: disable=bare-except + return False + + +def is_captcha_enabled(course_id) -> bool: + """ + Check if reCAPTCHA is enabled for discussion posts in the given course. + """ + return bool(ENABLE_CAPTCHA_IN_DISCUSSION.is_enabled(course_id) and settings.RECAPTCHA_PRIVATE_KEY) + + +def get_course_id_from_thread_id(thread_id: str) -> str: + """ + Get course id from thread id. + """ + thread = Thread(id=thread_id).retrieve(**{ + 'with_responses': False, + 'mark_as_read': False + }) + return thread["course_id"] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 2b0bf8c41e..766d2fd18f 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,11 +1,11 @@ """ Discussion API views """ - import logging import uuid import edx_api_doc_tools as apidocs + from django.contrib.auth import get_user_model from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 @@ -20,6 +20,7 @@ from rest_framework.parsers import JSONParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ViewSet + from xmodule.modulestore.django import modulestore from common.djangoapps.util.file import store_uploaded_file @@ -79,10 +80,9 @@ from ..rest_api.serializers import ( ) from .utils import ( create_blocks_params, - create_topics_v3_structure, + create_topics_v3_structure, is_captcha_enabled, verify_recaptcha_token, get_course_id_from_thread_id, ) - log = logging.getLogger(__name__) User = get_user_model() @@ -664,7 +664,18 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): Implements the POST method for the list endpoint as described in the class docstring. """ - return Response(create_thread(request, request.data)) + 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"))): + 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) + data = request.data.copy() + data.pop('captcha_token', None) + return Response(create_thread(request, data)) def partial_update(self, request, thread_id): """ @@ -1019,6 +1030,18 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): Implements the POST method for the list endpoint as described in the class docstring. """ + 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)): + 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) + data = request.data.copy() + data.pop('captcha_token', None) return Response(create_comment(request, request.data)) def destroy(self, request, comment_id): diff --git a/lms/envs/common.py b/lms/envs/common.py index ba77c23afb..c0fb469124 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3378,7 +3378,6 @@ INSTALLED_APPS = [ "openedx_learning.apps.authoring.sections", ] - ######################### CSRF ######################################### # Forwards-compatibility with Django 1.7 @@ -5684,3 +5683,18 @@ USE_EXTRACTED_PROBLEM_BLOCK = False # .. toggle_creation_date: 2024-11-10 # .. toggle_target_removal_date: 2025-06-01 USE_EXTRACTED_VIDEO_BLOCK = False + +# .. setting_name: RECAPTCHA_PRIVATE_KEY +# .. setting_default: empty string +# .. setting_description: Add recaptcha private key to use captcha feature in discussion app. +RECAPTCHA_PRIVATE_KEY = "" + +# .. setting_name: RECAPTCHA_VERIFY_URL +# .. setting_default: empty string +# .. setting_description: Add recaptcha verification api url to verify capthca tokens. +RECAPTCHA_VERIFY_URL = "" + +# .. setting_name: RECAPTCHA_SITE_KEY +# .. setting_default: empty string +# .. setting_description: Add recaptcha site key to use captcha feature in discussion MFE. +RECAPTCHA_SITE_KEY = ""