feat: added captcha validation in discussion thread/comment creation api (#37015)

This commit is contained in:
Ahtisham Shahid
2025-07-16 18:02:43 +05:00
committed by GitHub
parent 83a18cdfc8
commit cf93ba2974
7 changed files with 113 additions and 8 deletions

View File

@@ -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__)

View File

@@ -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,
}
}

View File

@@ -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(

View File

@@ -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': '',
},
}
)

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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 = ""