diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b87852c16cfa..d8ef15cd6a02 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -1,17 +1,17 @@ """ Discussion API internal interface """ + from __future__ import annotations import itertools +import logging import re from collections import defaultdict from datetime import datetime - from enum import Enum from typing import Dict, Iterable, List, Literal, Optional, Set, Tuple from urllib.parse import urlencode, urlunparse -from pytz import UTC from django.conf import settings from django.contrib.auth import get_user_model @@ -19,24 +19,26 @@ from django.db.models import Q from django.http import Http404 from django.urls import reverse +from django.utils.html import strip_tags from edx_django_utils.monitoring import function_trace from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseKey +from pytz import UTC from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, -) - +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from forum import api as forum_api 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.rate_limit import is_content_creation_rate_limited -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST +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, @@ -48,12 +50,12 @@ from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.course import ( get_course_commentable_counts, - get_course_user_stats + get_course_user_stats, ) from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( CommentClient500Error, - CommentClientRequestError + CommentClientRequestError, ) from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -61,13 +63,13 @@ FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, CourseDiscussionSettings, - Role + Role, ) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, comment_deleted, - comment_endorsed, comment_edited, + comment_endorsed, comment_flagged, comment_voted, thread_created, @@ -75,11 +77,15 @@ thread_edited, thread_flagged, thread_followed, + thread_unfollowed, thread_voted, - thread_unfollowed ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError +from openedx.core.lib.exceptions import ( + CourseNotFoundError, + DiscussionNotFoundError, + PageNotFoundError, +) from xmodule.course_block import CourseBlock from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -88,21 +94,27 @@ from ..django_comment_client.base.views import ( track_comment_created_event, track_comment_deleted_event, + track_discussion_reported_event, + track_discussion_unreported_event, + track_forum_search_event, track_thread_created_event, track_thread_deleted_event, + track_thread_followed_event, track_thread_viewed_event, track_voted_event, - track_discussion_reported_event, - track_discussion_unreported_event, - track_forum_search_event, track_thread_followed_event ) from ..django_comment_client.utils import ( get_group_id_for_user, get_user_role_names, has_discussion_privileges, - is_commentable_divided + is_commentable_divided, +) +from .exceptions import ( + CommentNotFoundError, + DiscussionBlackOutException, + DiscussionDisabledError, + ThreadNotFoundError, ) -from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering from .pagination import DiscussionAPIPagination from .permissions import ( @@ -110,7 +122,7 @@ can_take_action_on_spam, get_editable_fields, get_initializable_comment_fields, - get_initializable_thread_fields + get_initializable_thread_fields, ) from .serializers import ( CommentSerializer, @@ -119,20 +131,23 @@ ThreadSerializer, TopicOrdering, UserStatsSerializer, - get_context + get_context, ) from .utils import ( AttributeDict, add_stats_for_users_with_no_discussion_content, + can_user_notify_all_learners, create_blocks_params, discussion_open_for_user, + get_captcha_site_key_by_platform, get_usernames_for_course, get_usernames_from_search_string, - set_attribute, + is_captcha_enabled, is_posting_allowed, - can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform + set_attribute, ) +log = logging.getLogger(__name__) User = get_user_model() ThreadType = Literal["discussion", "question"] @@ -166,11 +181,14 @@ class DiscussionEntity(Enum): """ Enum for different types of discussion related entities """ - thread = 'thread' - comment = 'comment' + + thread = "thread" + comment = "comment" -def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> CourseBlock: +def _get_course( + course_key: CourseKey, user: User, check_tab: bool = True +) -> CourseBlock: """ Get the course block, raising CourseNotFoundError if the course is not found or the user cannot access forums for the course, and DiscussionDisabledError if the @@ -188,14 +206,16 @@ def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> Co CourseBlock: course object """ try: - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) + course = get_course_with_access( + user, "load", course_key, check_if_enrolled=True + ) except (Http404, CourseAccessRedirect) as err: # Convert 404s into CourseNotFoundErrors. # Raise course not found if the user cannot access the course raise CourseNotFoundError("Course not found.") from err if check_tab: - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") if not (discussion_tab and discussion_tab.is_enabled(course, user)): raise DiscussionDisabledError("Discussion is disabled for the course.") @@ -216,22 +236,34 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id= retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) + cc_thread = Thread(id=thread_id).retrieve( + course_id=course_id, **retrieve_kwargs + ) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) - if retrieve_kwargs.get("flagged_comments") and not context["has_moderation_privilege"]: + if ( + retrieve_kwargs.get("flagged_comments") + and not context["has_moderation_privilege"] + ): raise ValidationError("Only privileged users can request flagged comments") course_discussion_settings = CourseDiscussionSettings.get(course_key) if ( - not context["has_moderation_privilege"] and - cc_thread["group_id"] and - is_commentable_divided(course.id, cc_thread["commentable_id"], course_discussion_settings) + not context["has_moderation_privilege"] + and cc_thread["group_id"] + and is_commentable_divided( + course.id, cc_thread["commentable_id"], course_discussion_settings + ) ): - requester_group_id = get_group_id_for_user(request.user, course_discussion_settings) - if requester_group_id is not None and cc_thread["group_id"] != requester_group_id: + requester_group_id = get_group_id_for_user( + request.user, course_discussion_settings + ) + if ( + requester_group_id is not None + and cc_thread["group_id"] != requester_group_id + ): raise ThreadNotFoundError("Thread not found.") return cc_thread, context except CommentClientRequestError as err: @@ -264,8 +296,8 @@ def _is_user_author_or_privileged(cc_content, context): Boolean """ return ( - context["has_moderation_privilege"] or - context["cc_requester"]["id"] == cc_content["user_id"] + context["has_moderation_privilege"] + or context["cc_requester"]["id"] == cc_content["user_id"] ) @@ -275,11 +307,13 @@ def get_thread_list_url(request, course_key, topic_id_list=None, following=False """ path = reverse("thread-list") query_list = ( - [("course_id", str(course_key))] + - [("topic_id", topic_id) for topic_id in topic_id_list or []] + - ([("following", following)] if following else []) + [("course_id", str(course_key))] + + [("topic_id", topic_id) for topic_id in topic_id_list or []] + + ([("following", following)] if following else []) + ) + return request.build_absolute_uri( + urlunparse(("", "", path, "", urlencode(query_list), "")) ) - return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) def get_course(request, course_key, check_tab=True): @@ -324,18 +358,19 @@ def _format_datetime(dt): the substitution... though really, that would probably break mobile client parsing of the dates as well. :-P """ - return dt.isoformat().replace('+00:00', 'Z') + return dt.isoformat().replace("+00:00", "Z") course = _get_course(course_key, request.user, check_tab=check_tab) user_roles = get_user_role_names(request.user, course_key) course_config = DiscussionsConfiguration.get(course_key) EDIT_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_EDIT_REASON_CODES", {}) - CLOSE_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {}) + CLOSE_REASON_CODES = getattr( + settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {} + ) is_posting_enabled = is_posting_allowed( - course_config.posting_restrictions, - course.get_discussion_blackout_datetimes() + course_config.posting_restrictions, course.get_discussion_blackout_datetimes() ) - discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') + discussion_tab = CourseTabList.get_tab_by_type(course.tabs, "discussion") is_course_staff = CourseStaffRole(course_key).has_user(request.user) is_course_admin = CourseInstructorRole(course_key).has_user(request.user) return { @@ -349,7 +384,9 @@ def _format_datetime(dt): for blackout in course.get_discussion_blackout_datetimes() ], "thread_list_url": get_thread_list_url(request, course_key), - "following_thread_list_url": get_thread_list_url(request, course_key, following=True), + "following_thread_list_url": get_thread_list_url( + request, course_key, following=True + ), "topics_url": request.build_absolute_uri( reverse("course_topics", kwargs={"course_id": course_key}) ), @@ -357,18 +394,23 @@ def _format_datetime(dt): "allow_anonymous_to_peers": course.allow_anonymous_to_peers, "user_roles": user_roles, "has_bulk_delete_privileges": can_take_action_on_spam(request.user, course_key), - "has_moderation_privileges": bool(user_roles & { - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - }), + "has_moderation_privileges": bool( + user_roles + & { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + } + ), "is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}), "is_user_admin": request.user.is_staff, "is_course_staff": is_course_staff, "is_course_admin": is_course_admin, "provider": course_config.provider_type, "enable_in_context": course_config.enable_in_context, - "group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False), + "group_at_subsection": course_config.plugin_configuration.get( + "group_at_subsection", False + ), "edit_reasons": [ {"code": reason_code, "label": label} for (reason_code, label) in EDIT_REASON_CODES.items() @@ -377,17 +419,23 @@ def _format_datetime(dt): {"code": reason_code, "label": label} for (reason_code, label) in CLOSE_REASON_CODES.items() ], - 'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)), - 'is_notify_all_learners_enabled': can_user_notify_all_learners( + "show_discussions": bool( + discussion_tab and discussion_tab.is_enabled(course, request.user) + ), + "is_notify_all_learners_enabled": can_user_notify_all_learners( user_roles, is_course_staff, is_course_admin ), - 'captcha_settings': { - 'enabled': is_captcha_enabled(course_key), - 'site_key': get_captcha_site_key_by_platform('web'), + "captcha_settings": { + "enabled": is_captcha_enabled(course_key), + "site_key": get_captcha_site_key_by_platform("web"), }, "is_email_verified": request.user.is_active, - "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key), - "content_creation_rate_limited": is_content_creation_rate_limited(request, course_key, increment=False), + "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled( + course_key + ), + "content_creation_rate_limited": is_content_creation_rate_limited( + request, course_key, increment=False + ), } @@ -440,7 +488,7 @@ def convert(text): return text def alphanum_key(key): - return [convert(c) for c in re.split('([0-9]+)', key)] + return [convert(c) for c in re.split("([0-9]+)", key)] return sorted(category_list, key=alphanum_key) @@ -482,7 +530,7 @@ def get_non_courseware_topics( course_key: CourseKey, course: CourseBlock, topic_ids: Optional[List[str]], - thread_counts: Dict[str, Dict[str, int]] + thread_counts: Dict[str, Dict[str, int]], ) -> Tuple[List[Dict], Set[str]]: """ Returns a list of topic trees that are not linked to courseware. @@ -506,13 +554,17 @@ def get_non_courseware_topics( existing_topic_ids = set() topics = list(course.discussion_topics.items()) for name, entry in topics: - if not topic_ids or entry['id'] in topic_ids: + if not topic_ids or entry["id"] in topic_ids: discussion_topic = DiscussionTopic( - entry["id"], name, get_thread_list_url(request, course_key, [entry["id"]]), + entry["id"], + name, + get_thread_list_url(request, course_key, [entry["id"]]), None, - thread_counts.get(entry["id"]) + thread_counts.get(entry["id"]), + ) + non_courseware_topics.append( + DiscussionTopicSerializer(discussion_topic).data ) - non_courseware_topics.append(DiscussionTopicSerializer(discussion_topic).data) if topic_ids and entry["id"] in topic_ids: existing_topic_ids.add(entry["id"]) @@ -520,7 +572,9 @@ def get_non_courseware_topics( return non_courseware_topics, existing_topic_ids -def get_course_topics(request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None): +def get_course_topics( + request: Request, course_key: CourseKey, topic_ids: Optional[Set[str]] = None +): """ Returns the course topic listing for the given course and user; filtered by 'topic_ids' list if given. @@ -544,15 +598,25 @@ def get_course_topics(request: Request, course_key: CourseKey, topic_ids: Option courseware_topics, existing_courseware_topic_ids = get_courseware_topics( request, course_key, course, topic_ids, thread_counts ) - non_courseware_topics, existing_non_courseware_topic_ids = get_non_courseware_topics( - request, course_key, course, topic_ids, thread_counts, + non_courseware_topics, existing_non_courseware_topic_ids = ( + get_non_courseware_topics( + request, + course_key, + course, + topic_ids, + thread_counts, + ) ) if topic_ids: - not_found_topic_ids = topic_ids - (existing_courseware_topic_ids | existing_non_courseware_topic_ids) + not_found_topic_ids = topic_ids - ( + existing_courseware_topic_ids | existing_non_courseware_topic_ids + ) if not_found_topic_ids: raise DiscussionNotFoundError( - "Discussion not found for '{}'.".format(", ".join(str(id) for id in not_found_topic_ids)) + "Discussion not found for '{}'.".format( + ", ".join(str(id) for id in not_found_topic_ids) + ) ) return { @@ -567,17 +631,19 @@ def get_v2_non_courseware_topics_as_v1(request, course_key, topics): """ non_courseware_topics = [] for topic in topics: - if topic.get('usage_key', '') is None: - for key in ['usage_key', 'enabled_in_context']: + if topic.get("usage_key", "") is None: + for key in ["usage_key", "enabled_in_context"]: topic.pop(key) - topic.update({ - 'children': [], - 'thread_list_url': get_thread_list_url( - request, - course_key, - topic.get('id'), - ) - }) + topic.update( + { + "children": [], + "thread_list_url": get_thread_list_url( + request, + course_key, + topic.get("id"), + ), + } + ) non_courseware_topics.append(topic) return non_courseware_topics @@ -589,23 +655,25 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [] for sequential in sequentials: children = [] - for child in sequential.get('children', []): + for child in sequential.get("children", []): for topic in topics: - if child == topic.get('usage_key'): - topic.update({ - 'children': [], - 'thread_list_url': get_thread_list_url( - request, - course_key, - [topic.get('id')], - ) - }) - topic.pop('enabled_in_context') + if child == topic.get("usage_key"): + topic.update( + { + "children": [], + "thread_list_url": get_thread_list_url( + request, + course_key, + [topic.get("id")], + ), + } + ) + topic.pop("enabled_in_context") children.append(AttributeDict(topic)) discussion_topic = DiscussionTopic( None, - sequential.get('display_name'), + sequential.get("display_name"), get_thread_list_url( request, course_key, @@ -618,7 +686,7 @@ def get_v2_courseware_topics_as_v1(request, course_key, sequentials, topics): courseware_topics = [ courseware_topic for courseware_topic in courseware_topics - if courseware_topic.get('children', []) + if courseware_topic.get("children", []) ] return courseware_topics @@ -635,20 +703,21 @@ def get_v2_course_topics_as_v1( blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params['usage_key'], - blocks_params['user'], - blocks_params['depth'], - blocks_params['nav_depth'], - blocks_params['requested_fields'], - blocks_params['block_counts'], - blocks_params['student_view_data'], - blocks_params['return_type'], - blocks_params['block_types_filter'], + blocks_params["usage_key"], + blocks_params["user"], + blocks_params["depth"], + blocks_params["nav_depth"], + blocks_params["requested_fields"], + blocks_params["block_counts"], + blocks_params["student_view_data"], + blocks_params["return_type"], + blocks_params["block_types_filter"], hide_access_denials=False, - )['blocks'] + )["blocks"] - sequentials = [value for _, value in blocks.items() - if value.get('type') == "sequential"] + sequentials = [ + value for _, value in blocks.items() if value.get("type") == "sequential" + ] topics = get_course_topics_v2(course_key, request.user, topic_ids) non_courseware_topics = get_v2_non_courseware_topics_as_v1( @@ -705,24 +774,29 @@ def get_course_topics_v2( # Check access to the course store = modulestore() _get_course(course_key, user=user, check_tab=False) - user_is_privileged = user.is_staff or user.roles.filter( - course_id=course_key, - name__in=[ - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_ADMINISTRATOR, - ] - ).exists() + user_is_privileged = ( + user.is_staff + or user.roles.filter( + course_id=course_key, + name__in=[ + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_ADMINISTRATOR, + ], + ).exists() + ) with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): blocks = store.get_items( course_key, - qualifiers={'category': 'vertical'}, - fields=['usage_key', 'discussion_enabled', 'display_name'], + qualifiers={"category": "vertical"}, + fields=["usage_key", "discussion_enabled", "display_name"], ) accessible_vertical_keys = [] for block in blocks: - if block.discussion_enabled and (not block.visible_to_staff_only or user_is_privileged): + if block.discussion_enabled and ( + not block.visible_to_staff_only or user_is_privileged + ): accessible_vertical_keys.append(block.usage_key) accessible_vertical_keys.append(None) @@ -732,9 +806,13 @@ def get_course_topics_v2( ) if user_is_privileged: - topics_query = topics_query.filter(Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False)) + topics_query = topics_query.filter( + Q(usage_key__in=accessible_vertical_keys) | Q(enabled_in_context=False) + ) else: - topics_query = topics_query.filter(usage_key__in=accessible_vertical_keys, enabled_in_context=True) + topics_query = topics_query.filter( + usage_key__in=accessible_vertical_keys, enabled_in_context=True + ) if topic_ids: topics_query = topics_query.filter(external_id__in=topic_ids) @@ -746,11 +824,13 @@ def get_course_topics_v2( reverse=True, ) elif order_by == TopicOrdering.NAME: - topics_query = topics_query.order_by('title') + topics_query = topics_query.order_by("title") else: - topics_query = topics_query.order_by('ordering') + topics_query = topics_query.order_by("ordering") - topics_data = DiscussionTopicSerializerV2(topics_query, many=True, context={"thread_counts": thread_counts}).data + topics_data = DiscussionTopicSerializerV2( + topics_query, many=True, context={"thread_counts": thread_counts} + ).data return [ topic_data for topic_data in topics_data @@ -777,7 +857,7 @@ def _get_user_profile_dict(request, usernames): else: username_list = [] user_profile_details = get_account_settings(request, username_list) - return {user['username']: user for user in user_profile_details} + return {user["username"]: user for user in user_profile_details} def _user_profile(user_profile): @@ -785,11 +865,7 @@ def _user_profile(user_profile): Returns the user profile object. For now, this just comprises the profile_image details. """ - return { - 'profile': { - 'image': user_profile['profile_image'] - } - } + return {"profile": {"image": user_profile["profile_image"]}} def _get_users(discussion_entity_type, discussion_entity, username_profile_dict): @@ -807,22 +883,28 @@ def _get_users(discussion_entity_type, discussion_entity, username_profile_dict) A dict of users with username as key and user profile details as value. """ users = {} - if discussion_entity['author']: - user_profile = username_profile_dict.get(discussion_entity['author']) + if discussion_entity["author"]: + user_profile = username_profile_dict.get(discussion_entity["author"]) if user_profile: - users[discussion_entity['author']] = _user_profile(user_profile) + users[discussion_entity["author"]] = _user_profile(user_profile) if ( discussion_entity_type == DiscussionEntity.comment - and discussion_entity['endorsed'] - and discussion_entity['endorsed_by'] + and discussion_entity["endorsed"] + and discussion_entity["endorsed_by"] ): - users[discussion_entity['endorsed_by']] = _user_profile(username_profile_dict[discussion_entity['endorsed_by']]) + users[discussion_entity["endorsed_by"]] = _user_profile( + username_profile_dict[discussion_entity["endorsed_by"]] + ) return users def _add_additional_response_fields( - request, serialized_discussion_entities, usernames, discussion_entity_type, include_profile_image + request, + serialized_discussion_entities, + usernames, + discussion_entity_type, + include_profile_image, ): """ Adds additional data to serialized discussion thread/comment. @@ -840,9 +922,13 @@ def _add_additional_response_fields( A list of serialized discussion thread/comment with additional data if requested. """ if include_profile_image: - username_profile_dict = _get_user_profile_dict(request, usernames=','.join(usernames)) + username_profile_dict = _get_user_profile_dict( + request, usernames=",".join(usernames) + ) for discussion_entity in serialized_discussion_entities: - discussion_entity['users'] = _get_users(discussion_entity_type, discussion_entity, username_profile_dict) + discussion_entity["users"] = _get_users( + discussion_entity_type, discussion_entity, username_profile_dict + ) return serialized_discussion_entities @@ -851,10 +937,12 @@ def _include_profile_image(requested_fields): """ Returns True if requested_fields list has 'profile_image' entity else False """ - return requested_fields and 'profile_image' in requested_fields + return requested_fields and "profile_image" in requested_fields -def _serialize_discussion_entities(request, context, discussion_entities, requested_fields, discussion_entity_type): +def _serialize_discussion_entities( + request, context, discussion_entities, requested_fields, discussion_entity_type +): """ It serializes Discussion Entity (Thread or Comment) and add additional data if requested. @@ -885,14 +973,19 @@ def _serialize_discussion_entities(request, context, discussion_entities, reques results.append(serialized_entity) if include_profile_image: - if serialized_entity['author'] and serialized_entity['author'] not in usernames: - usernames.append(serialized_entity['author']) if ( - 'endorsed' in serialized_entity and serialized_entity['endorsed'] and - 'endorsed_by' in serialized_entity and - serialized_entity['endorsed_by'] and serialized_entity['endorsed_by'] not in usernames + serialized_entity["author"] + and serialized_entity["author"] not in usernames ): - usernames.append(serialized_entity['endorsed_by']) + usernames.append(serialized_entity["author"]) + if ( + "endorsed" in serialized_entity + and serialized_entity["endorsed"] + and "endorsed_by" in serialized_entity + and serialized_entity["endorsed_by"] + and serialized_entity["endorsed_by"] not in usernames + ): + usernames.append(serialized_entity["endorsed_by"]) results = _add_additional_response_fields( request, results, usernames, discussion_entity_type, include_profile_image @@ -916,6 +1009,7 @@ def get_thread_list( order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, + show_deleted: bool = False, ): """ Return the list of all discussion threads pertaining to the given course @@ -959,20 +1053,31 @@ def get_thread_list( CourseNotFoundError: if the requesting user does not have access to the requested course PageNotFoundError: if page requested is beyond the last """ - exclusive_param_count = sum(1 for param in [topic_id_list, text_search, following] if param) + exclusive_param_count = sum( + 1 for param in [topic_id_list, text_search, following] if param + ) if exclusive_param_count > 1: # pragma: no cover - raise ValueError("More than one mutually exclusive param passed to get_thread_list") + raise ValueError( + "More than one mutually exclusive param passed to get_thread_list" + ) - cc_map = {"last_activity_at": "activity", "comment_count": "comments", "vote_count": "votes"} + cc_map = { + "last_activity_at": "activity", + "comment_count": "comments", + "vote_count": "votes", + } if order_by not in cc_map: - raise ValidationError({ - "order_by": - [f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'"] - }) + raise ValidationError( + { + "order_by": [ + f"Invalid value. '{order_by}' must be 'last_activity_at', 'comment_count', or 'vote_count'" + ] + } + ) if order_direction != "desc": - raise ValidationError({ - "order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"] - }) + raise ValidationError( + {"order_direction": [f"Invalid value. '{order_direction}' must be 'desc'"]} + ) course = _get_course(course_key, request.user) context = get_context(course, request) @@ -984,13 +1089,21 @@ def get_thread_list( except User.DoesNotExist: # Raising an error for a missing user leaks the presence of a username, # so just return an empty response. - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - "text_search_rewrite": None, - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + "text_search_rewrite": None, + } + ) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied("`count_flagged` can only be set by users with moderator access or higher.") + raise PermissionDenied( + "`count_flagged` can only be set by users with moderator access or higher." + ) + if show_deleted and not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderator access or higher." + ) group_id = None allowed_roles = [ @@ -1010,7 +1123,9 @@ def get_thread_list( not context["has_moderation_privilege"] or request.user.id in context["ta_user_ids"] ): - group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) + group_id = get_group_id_for_user( + request.user, CourseDiscussionSettings.get(course.id) + ) query_params = { "user_id": str(request.user.id), @@ -1023,21 +1138,24 @@ def get_thread_list( "flagged": flagged, "thread_type": thread_type, "count_flagged": count_flagged, + "show_deleted": show_deleted, } if view: if view in ["unread", "unanswered", "unresponded"]: query_params[view] = "true" else: - raise ValidationError({ - "view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"] - }) + raise ValidationError( + {"view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"]} + ) if following: paginated_results = context["cc_requester"].subscribed_threads(query_params) else: query_params["course_id"] = str(course.id) - query_params["commentable_ids"] = ",".join(topic_id_list) if topic_id_list else None + query_params["commentable_ids"] = ( + ",".join(topic_id_list) if topic_id_list else None + ) query_params["text"] = text_search paginated_results = Thread.search(query_params) # The comments service returns the last page of results if the requested @@ -1047,19 +1165,25 @@ def get_thread_list( raise PageNotFoundError("Page not found (No results on this page).") results = _serialize_discussion_entities( - request, context, paginated_results.collection, requested_fields, DiscussionEntity.thread + request, + context, + paginated_results.collection, + requested_fields, + DiscussionEntity.thread, ) paginator = DiscussionAPIPagination( request, paginated_results.page, paginated_results.num_pages, - paginated_results.thread_count + paginated_results.thread_count, + ) + return paginator.get_paginated_response( + { + "results": results, + "text_search_rewrite": paginated_results.corrected_text, + } ) - return paginator.get_paginated_response({ - "results": results, - "text_search_rewrite": paginated_results.corrected_text, - }) def get_learner_active_thread_list(request, course_key, query_params): @@ -1154,49 +1278,101 @@ def get_learner_active_thread_list(request, course_key, query_params): course = _get_course(course_key, request.user) context = get_context(course, request) - group_id = query_params.get('group_id', None) - user_id = query_params.get('user_id', None) - count_flagged = query_params.get('count_flagged', None) + group_id = query_params.get("group_id", None) + user_id = query_params.get("user_id", None) + count_flagged = query_params.get("count_flagged", None) + show_deleted = query_params.get("show_deleted", False) + if isinstance(show_deleted, str): + show_deleted = show_deleted.lower() == "true" + if user_id is None: - return Response({'detail': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Invalid user id"}, status=status.HTTP_400_BAD_REQUEST + ) if count_flagged and not context["has_moderation_privilege"]: - raise PermissionDenied("count_flagged can only be set by users with moderation roles.") + raise PermissionDenied( + "count_flagged can only be set by users with moderation roles." + ) if "flagged" in query_params.keys() and not context["has_moderation_privilege"]: raise PermissionDenied("Flagged filter is only available for moderators") + if show_deleted and not context["has_moderation_privilege"]: + raise PermissionDenied( + "show_deleted can only be set by users with moderation roles." + ) if group_id is None: comment_client_user = comment_client.User(id=user_id, course_id=course_key) else: - comment_client_user = comment_client.User(id=user_id, course_id=course_key, group_id=group_id) + comment_client_user = comment_client.User( + id=user_id, course_id=course_key, group_id=group_id + ) try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) + + # This portion below is temporary until we migrate to forum v2 + filtered_threads = [] + for thread in threads: + try: + forum_thread = forum_api.get_thread( + thread.get("id"), course_id=str(course_key) + ) + is_deleted = forum_thread.get("is_deleted", False) + + if show_deleted and is_deleted: + thread["is_deleted"] = True + thread["deleted_at"] = forum_thread.get("deleted_at") + thread["deleted_by"] = forum_thread.get("deleted_by") + filtered_threads.append(thread) + elif not show_deleted and not is_deleted: + filtered_threads.append(thread) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to check thread %s deletion status: %s", thread.get("id"), e + ) + if not show_deleted: # Fail safe: include thread for regular users + filtered_threads.append(thread) + results = _serialize_discussion_entities( - request, context, threads, {'profile_image'}, DiscussionEntity.thread + request, + context, + filtered_threads, + {"profile_image"}, + DiscussionEntity.thread, ) paginator = DiscussionAPIPagination( - request, - page, - num_pages, - len(threads) + request, page, num_pages, len(filtered_threads) + ) + return paginator.get_paginated_response( + { + "results": results, + } ) - return paginator.get_paginated_response({ - "results": results, - }) except CommentClient500Error: return DiscussionAPIPagination( request, page_num=1, num_pages=0, - ).get_paginated_response({ - "results": [], - }) + ).get_paginated_response( + { + "results": [], + } + ) -def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None, - merge_question_type_responses=False): +def get_comment_list( + request, + thread_id, + endorsed, + page, + page_size, + flagged=False, + requested_fields=None, + merge_question_type_responses=False, + show_deleted=False, +): """ Return the list of comments in the given thread. @@ -1226,7 +1402,7 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals discussion.rest_api.views.CommentViewSet for more detail. """ response_skip = page_size * (page - 1) - reverse_order = request.GET.get('reverse_order', False) + reverse_order = request.GET.get("reverse_order", False) from_mfe_sidebar = request.GET.get("enable_in_context_sidebar", False) cc_thread, context = _get_thread_and_context( request, @@ -1239,19 +1415,23 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals "response_skip": response_skip, "response_limit": page_size, "reverse_order": reverse_order, - "merge_question_type_responses": merge_question_type_responses - } + "merge_question_type_responses": merge_question_type_responses, + }, ) # Responses to discussion threads cannot be separated by endorsed, but # responses to question threads must be separated by endorsed due to the # existing comments service interface if cc_thread["thread_type"] == "question" and not merge_question_type_responses: if endorsed is None: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError({"endorsed": ["This field is required for question threads."]}) + raise ValidationError( + {"endorsed": ["This field is required for question threads."]} + ) elif endorsed: # CS does not apply resp_skip and resp_limit to endorsed responses # of a question post - responses = cc_thread["endorsed_responses"][response_skip:(response_skip + page_size)] + responses = cc_thread["endorsed_responses"][ + response_skip: (response_skip + page_size) + ] resp_total = len(cc_thread["endorsed_responses"]) else: responses = cc_thread["non_endorsed_responses"] @@ -1260,7 +1440,11 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals if not merge_question_type_responses: if endorsed is not None: raise ValidationError( - {"endorsed": ["This field may not be specified for discussion threads."]} + { + "endorsed": [ + "This field may not be specified for discussion threads." + ] + } ) responses = cc_thread["children"] resp_total = cc_thread["resp_total"] @@ -1272,9 +1456,21 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) + if not show_deleted: + responses = [ + response for response in responses if not response.get("is_deleted", False) + ] + else: + if not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderation roles." + ) + + results = _serialize_discussion_entities( + request, context, responses, requested_fields, DiscussionEntity.comment + ) - paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) + paginator = DiscussionAPIPagination(request, page, num_pages, len(responses)) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) @@ -1292,7 +1488,9 @@ def _check_fields(allowed_fields, data, message): ValidationError if the given data contains a key that is not in allowed_fields """ - non_allowed_fields = {field: [message] for field in data.keys() if field not in allowed_fields} + non_allowed_fields = { + field: [message] for field in data.keys() if field not in allowed_fields + } if non_allowed_fields: raise ValidationError(non_allowed_fields) @@ -1314,7 +1512,7 @@ def _check_initializable_thread_fields(data, context): _check_fields( get_initializable_thread_fields(context), data, - "This field is not initializable." + "This field is not initializable.", ) @@ -1335,7 +1533,7 @@ def _check_initializable_comment_fields(data, context): _check_fields( get_initializable_comment_fields(context), data, - "This field is not initializable." + "This field is not initializable.", ) @@ -1345,28 +1543,40 @@ def _check_editable_fields(cc_content, data, context): editable by the requesting user """ _check_fields( - get_editable_fields(cc_content, context), - data, - "This field is not editable." + get_editable_fields(cc_content, context), data, "This field is not editable." ) -def _do_extra_actions(api_content, cc_content, request_fields, actions_form, context, request): +def _do_extra_actions( + api_content, cc_content, request_fields, actions_form, context, request +): """ Perform any necessary additional actions related to content creation or update that require a separate comments service request. """ for field, form_value in actions_form.cleaned_data.items(): - if field in request_fields and field in api_content and form_value != api_content[field]: + if ( + field in request_fields + and field in api_content + and form_value != api_content[field] + ): api_content[field] = form_value if field == "following": - _handle_following_field(form_value, context["cc_requester"], cc_content, request) + _handle_following_field( + form_value, context["cc_requester"], cc_content, request + ) elif field == "abuse_flagged": - _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request) + _handle_abuse_flagged_field( + form_value, context["cc_requester"], cc_content, request + ) elif field == "voted": - _handle_voted_field(form_value, cc_content, api_content, request, context) + _handle_voted_field( + form_value, cc_content, api_content, request, context + ) elif field == "read": - _handle_read_field(api_content, form_value, context["cc_requester"], cc_content) + _handle_read_field( + api_content, form_value, context["cc_requester"], cc_content + ) elif field == "pinned": _handle_pinned_field(form_value, cc_content, context["cc_requester"]) else: @@ -1376,7 +1586,7 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con def _handle_following_field(form_value, user, cc_content, request): """follow/unfollow thread for the user""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, 'load', course_key) + course = get_course_with_access(request.user, "load", course_key) if form_value: user.follow(cc_content) else: @@ -1389,15 +1599,19 @@ def _handle_following_field(form_value, user, cc_content, request): def _handle_abuse_flagged_field(form_value, user, cc_content, request): """mark or unmark thread/comment as abused""" course_key = CourseKey.from_string(cc_content.course_id) - course = get_course_with_access(request.user, 'load', course_key) + course = get_course_with_access(request.user, "load", course_key) if form_value: cc_content.flagAbuse(user, cc_content) track_discussion_reported_event(request, course, cc_content) if ENABLE_DISCUSSIONS_MFE.is_enabled(course_key): - if cc_content.type == 'thread': - thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content) + if cc_content.type == "thread": + thread_flagged.send( + sender="flag_abuse_for_thread", user=user, post=cc_content + ) else: - comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content) + comment_flagged.send( + sender="flag_abuse_for_comment", user=user, post=cc_content + ) else: remove_all = bool(is_privileged_user(course_key, User.objects.get(id=user.id))) cc_content.unFlagAbuse(user, cc_content, remove_all) @@ -1406,7 +1620,7 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request): def _handle_voted_field(form_value, cc_content, api_content, request, context): """vote or undo vote on thread/comment""" - signal = thread_voted if cc_content.type == 'thread' else comment_voted + signal = thread_voted if cc_content.type == "thread" else comment_voted signal.send(sender=None, user=context["request"].user, post=cc_content) if form_value: context["cc_requester"].vote(cc_content, "up") @@ -1415,7 +1629,11 @@ def _handle_voted_field(form_value, cc_content, api_content, request, context): context["cc_requester"].unvote(cc_content) api_content["vote_count"] -= 1 track_voted_event( - request, context["course"], cc_content, vote_value="up", undo_vote=not form_value + request, + context["course"], + cc_content, + vote_value="up", + undo_vote=not form_value, ) @@ -1423,7 +1641,7 @@ def _handle_read_field(api_content, form_value, user, cc_content): """ Marks thread as read for the user """ - if form_value and not cc_content['read']: + if form_value and not cc_content["read"]: user.read(cc_content) # When a thread is marked as read, all of its responses and comments # are also marked as read. @@ -1490,24 +1708,35 @@ def create_thread(request, thread_data): context = get_context(course, request) _check_initializable_thread_fields(thread_data, context) discussion_settings = CourseDiscussionSettings.get(course_key) - if ( - "group_id" not in thread_data and - is_commentable_divided(course_key, thread_data.get("topic_id"), discussion_settings) + if "group_id" not in thread_data and is_commentable_divided( + course_key, thread_data.get("topic_id"), discussion_settings ): thread_data = thread_data.copy() thread_data["group_id"] = get_group_id_for_user(user, discussion_settings) serializer = ThreadSerializer(data=thread_data, context=context) actions_form = ThreadActionsForm(thread_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) serializer.save() cc_thread = serializer.instance - thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) + thread_created.send( + sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners + ) api_thread = serializer.data - _do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request) + _do_extra_actions( + api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request + ) - track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"], - from_mfe_sidebar, notify_all_learners) + track_thread_created_event( + request, + course, + cc_thread, + actions_form.cleaned_data["following"], + from_mfe_sidebar, + notify_all_learners, + ) return api_thread @@ -1546,15 +1775,30 @@ def create_comment(request, comment_data): serializer = CommentSerializer(data=comment_data, context=context) actions_form = CommentActionsForm(comment_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) context["cc_requester"].follow(cc_thread) serializer.save() cc_comment = serializer.instance comment_created.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request) - track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False, - from_mfe_sidebar=from_mfe_sidebar) + _do_extra_actions( + api_comment, + cc_comment, + list(comment_data.keys()), + actions_form, + context, + request, + ) + track_comment_created_event( + request, + course, + cc_comment, + cc_thread["commentable_id"], + followed=False, + from_mfe_sidebar=from_mfe_sidebar, + ) return api_comment @@ -1576,24 +1820,32 @@ def update_thread(request, thread_id, update_data): The updated thread; see discussion.rest_api.views.ThreadViewSet for more detail. """ - cc_thread, context = _get_thread_and_context(request, thread_id, retrieve_kwargs={"with_responses": True}) + cc_thread, context = _get_thread_and_context( + request, thread_id, retrieve_kwargs={"with_responses": True} + ) _check_editable_fields(cc_thread, update_data, context) - serializer = ThreadSerializer(cc_thread, data=update_data, partial=True, context=context) + serializer = ThreadSerializer( + cc_thread, data=update_data, partial=True, context=context + ) actions_form = ThreadActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) # Only save thread object if some of the edited fields are in the thread data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() # signal to update Teams when a user edits a thread thread_edited.send(sender=None, user=request.user, post=cc_thread) api_thread = serializer.data - _do_extra_actions(api_thread, cc_thread, list(update_data.keys()), actions_form, context, request) + _do_extra_actions( + api_thread, cc_thread, list(update_data.keys()), actions_form, context, request + ) # always return read as True (and therefore unread_comment_count=0) as reasonably # accurate shortcut, rather than adding additional processing. - api_thread['read'] = True - api_thread['unread_comment_count'] = 0 + api_thread["read"] = True + api_thread["unread_comment_count"] = 0 return api_thread @@ -1628,16 +1880,27 @@ def update_comment(request, comment_id, update_data): """ cc_comment, context = _get_comment_and_context(request, comment_id) _check_editable_fields(cc_comment, update_data, context) - serializer = CommentSerializer(cc_comment, data=update_data, partial=True, context=context) + serializer = CommentSerializer( + cc_comment, data=update_data, partial=True, context=context + ) actions_form = CommentActionsForm(update_data) if not (serializer.is_valid() and actions_form.is_valid()): - raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + raise ValidationError( + dict(list(serializer.errors.items()) + list(actions_form.errors.items())) + ) # Only save comment object if some of the edited fields are in the comment data, not extra actions if set(update_data) - set(actions_form.fields): serializer.save() comment_edited.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data - _do_extra_actions(api_comment, cc_comment, list(update_data.keys()), actions_form, context, request) + _do_extra_actions( + api_comment, + cc_comment, + list(update_data.keys()), + actions_form, + context, + request, + ) _handle_comment_signals(update_data, cc_comment, request.user) return api_comment @@ -1671,7 +1934,9 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") - return _serialize_discussion_entities(request, context, [cc_thread], requested_fields, DiscussionEntity.thread)[0] + return _serialize_discussion_entities( + request, context, [cc_thread], requested_fields, DiscussionEntity.thread + )[0] def get_response_comments(request, comment_id, page, page_size, requested_fields=None): @@ -1699,7 +1964,10 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields """ try: cc_comment = Comment(id=comment_id).retrieve() - reverse_order = request.GET.get('reverse_order', False) + reverse_order = request.GET.get("reverse_order", False) + show_deleted = request.GET.get("show_deleted", False) + show_deleted = show_deleted in ["true", "True", True] + cc_thread, context = _get_thread_and_context( request, cc_comment["thread_id"], @@ -1707,10 +1975,13 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields "with_responses": True, "recursive": True, "reverse_order": reverse_order, - } + "show_deleted": show_deleted, + }, ) if cc_thread["thread_type"] == "question": - thread_responses = itertools.chain(cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"]) + thread_responses = itertools.chain( + cc_thread["endorsed_responses"], cc_thread["non_endorsed_responses"] + ) else: thread_responses = cc_thread["children"] response_comments = [] @@ -1720,16 +1991,35 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields break response_skip = page_size * (page - 1) - paged_response_comments = response_comments[response_skip:(response_skip + page_size)] + paged_response_comments = response_comments[ + response_skip: (response_skip + page_size) + ] if not paged_response_comments and page != 1: raise PageNotFoundError("Page not found (No results on this page).") + if not show_deleted: + paged_response_comments = [ + response + for response in paged_response_comments + if not response.get("is_deleted", False) + ] + else: + if not context["has_moderation_privilege"]: + raise PermissionDenied( + "`show_deleted` can only be set by users with moderation roles." + ) results = _serialize_discussion_entities( - request, context, paged_response_comments, requested_fields, DiscussionEntity.comment + request, + context, + paged_response_comments, + requested_fields, + DiscussionEntity.comment, ) - comments_count = len(response_comments) - num_pages = (comments_count + page_size - 1) // page_size if comments_count else 1 + comments_count = len(paged_response_comments) + num_pages = ( + (comments_count + page_size - 1) // page_size if comments_count else 1 + ) paginator = DiscussionAPIPagination(request, page, num_pages, comments_count) return paginator.get_paginated_response(results) except CommentClientRequestError as err: @@ -1773,16 +2063,20 @@ def get_user_comments( context = get_context(course, request) if flagged and not context["has_moderation_privilege"]: - raise ValidationError("Only privileged users can filter comments by flagged status") + raise ValidationError( + "Only privileged users can filter comments by flagged status" + ) try: - response = Comment.retrieve_all({ - 'user_id': author.id, - 'course_id': str(course_key), - 'flagged': flagged, - 'page': page, - 'per_page': page_size, - }) + response = Comment.retrieve_all( + { + "user_id": author.id, + "course_id": str(course_key), + "flagged": flagged, + "page": page, + "per_page": page_size, + } + ) except CommentClientRequestError as err: raise CommentNotFoundError("Comment not found") from err @@ -1822,7 +2116,7 @@ def delete_thread(request, thread_id): """ cc_thread, context = _get_thread_and_context(request, thread_id) if can_delete(cc_thread, context): - cc_thread.delete() + cc_thread.delete(deleted_by=str(request.user.id)) thread_deleted.send(sender=None, user=request.user, post=cc_thread) track_thread_deleted_event(request, context["course"], cc_thread) else: @@ -1847,7 +2141,7 @@ def delete_comment(request, comment_id): """ cc_comment, context = _get_comment_and_context(request, comment_id) if can_delete(cc_comment, context): - cc_comment.delete() + cc_comment.delete(deleted_by=str(request.user.id)) comment_deleted.send(sender=None, user=request.user, post=cc_comment) track_comment_deleted_event(request, context["course"], cc_comment) else: @@ -1879,7 +2173,10 @@ def get_course_discussion_user_stats( """ course_key = CourseKey.from_string(course_key_str) - is_privileged = has_discussion_privileges(user=request.user, course_id=course_key) or request.user.is_staff + is_privileged = ( + has_discussion_privileges(user=request.user, course_id=course_key) + or request.user.is_staff + ) if is_privileged: order_by = order_by or UserOrdering.BY_FLAGS else: @@ -1888,30 +2185,35 @@ def get_course_discussion_user_stats( raise ValidationError({"order_by": "Invalid value"}) params = { - 'sort_key': str(order_by), - 'page': page, - 'per_page': page_size, + "sort_key": str(order_by), + "page": page, + "per_page": page_size, } comma_separated_usernames = matched_users_count = matched_users_pages = None if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( - course_key, username_search_string, page, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_from_search_string( + course_key, username_search_string, page, page_size + ) ) search_event_data = { - 'query': username_search_string, - 'search_type': 'Learner', - 'page': params.get('page'), - 'sort_key': params.get('sort_key'), - 'total_results': matched_users_count, + "query": username_search_string, + "search_type": "Learner", + "page": params.get("page"), + "sort_key": params.get("sort_key"), + "total_results": matched_users_count, } course = _get_course(course_key, request.user) track_forum_search_event(request, course, search_event_data) + if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + } + ) - params['usernames'] = comma_separated_usernames + params["usernames"] = comma_separated_usernames course_stats_response = get_course_user_stats(course_key, params) @@ -1931,71 +2233,431 @@ def get_course_discussion_user_stats( paginator = DiscussionAPIPagination( request, course_stats_response["page"], - matched_users_pages if username_search_string else course_stats_response["num_pages"], - matched_users_count if username_search_string else course_stats_response["count"], + ( + matched_users_pages + if username_search_string + else course_stats_response["num_pages"] + ), + ( + matched_users_count + if username_search_string + else course_stats_response["count"] + ), + ) + return paginator.get_paginated_response( + { + "results": serializer.data, + } ) - return paginator.get_paginated_response({ - "results": serializer.data, - }) def get_users_without_stats( - username_search_string, - course_key, - page_number, - page_size, - request, - is_privileged + username_search_string, course_key, page_number, page_size, request, is_privileged ): """ This return users with no user stats. This function will be deprecated when this ticket DOS-3414 is resolved """ if username_search_string: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string( - course_key, username_search_string, page_number, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_from_search_string( + course_key, username_search_string, page_number, page_size + ) ) if not comma_separated_usernames: - return DiscussionAPIPagination(request, 0, 1).get_paginated_response({ - "results": [], - }) + return DiscussionAPIPagination(request, 0, 1).get_paginated_response( + { + "results": [], + } + ) else: - comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_for_course( - course_key, page_number, page_size + comma_separated_usernames, matched_users_count, matched_users_pages = ( + get_usernames_for_course(course_key, page_number, page_size) ) if comma_separated_usernames: - updated_course_stats = add_stats_for_users_with_null_values([], comma_separated_usernames) + updated_course_stats = add_stats_for_users_with_null_values( + [], comma_separated_usernames + ) - serializer = UserStatsSerializer(updated_course_stats, context={"is_privileged": is_privileged}, many=True) + serializer = UserStatsSerializer( + updated_course_stats, context={"is_privileged": is_privileged}, many=True + ) paginator = DiscussionAPIPagination( request, page_number, matched_users_pages, matched_users_count, ) - return paginator.get_paginated_response({ - "results": serializer.data, - }) + return paginator.get_paginated_response( + { + "results": serializer.data, + } + ) def add_stats_for_users_with_null_values(course_stats, users_in_course): """ Update users stats for users with no discussion stats available in course """ - users_returned_from_api = [user['username'] for user in course_stats] - user_list = users_in_course.split(',') + users_returned_from_api = [user["username"] for user in course_stats] + user_list = users_in_course.split(",") users_with_no_discussion_content = set(user_list) ^ set(users_returned_from_api) updated_course_stats = course_stats for user in users_with_no_discussion_content: - updated_course_stats.append({ - 'username': user, - 'threads': None, - 'replies': None, - 'responses': None, - 'active_flags': None, - 'inactive_flags': None, - }) - updated_course_stats = sorted(updated_course_stats, key=lambda d: len(d['username'])) + updated_course_stats.append( + { + "username": user, + "threads": None, + "replies": None, + "responses": None, + "active_flags": None, + "inactive_flags": None, + } + ) + updated_course_stats = sorted( + updated_course_stats, key=lambda d: len(d["username"]) + ) return updated_course_stats + + +def _get_user_label_function(course_staff_user_ids, moderator_user_ids, ta_user_ids): + """ + Create and return a function that determines user labels based on role. + + Args: + course_staff_user_ids: List of user IDs for course staff + moderator_user_ids: List of user IDs for moderators + ta_user_ids: List of user IDs for TAs + + Returns: + A function that takes a user_id and returns the appropriate label or None + """ + def get_user_label(user_id): + """Get role label for a user ID.""" + if not user_id: + return None + try: + user_id_int = int(user_id) + if user_id_int in course_staff_user_ids: + return "Staff" + elif user_id_int in moderator_user_ids: + return "Moderator" + elif user_id_int in ta_user_ids: + return "Community TA" + except (ValueError, TypeError): + pass + return None + return get_user_label + + +def _process_deleted_thread(thread_data, get_user_label_fn, usernames_set): + """ + Process a single deleted thread into the standardized content item format. + + Args: + thread_data: Raw thread data from forum API + get_user_label_fn: Function to get user labels by user ID + usernames_set: Set to collect usernames for profile image fetch (modified in-place) + + Returns: + dict: Formatted content item for the thread + """ + author_username = thread_data.get("author_username", "") + deleted_by_id = thread_data.get("deleted_by") + deleted_by_username = None + + # Get deleted_by username + if deleted_by_id: + try: + deleted_user = User.objects.get(id=int(deleted_by_id)) + deleted_by_username = deleted_user.username + usernames_set.add(deleted_by_username) + except (User.DoesNotExist, ValueError): + pass + + if author_username: + usernames_set.add(author_username) + + # Strip HTML tags from preview + body_text = thread_data.get("body", "") + preview_text = strip_tags(body_text)[:100] if body_text else "" + + thread_id = thread_data.get("_id", thread_data.get("id")) + return { + "id": str(thread_id) + "-thread", + "type": "thread", + "title": thread_data.get("title", ""), + "body": body_text, + "preview_body": preview_text, + "course_id": thread_data.get("course_id", ""), + "author": author_username, + "author_id": thread_data.get("author_id", ""), + "author_label": get_user_label_fn(thread_data.get("author_id")), + "commentable_id": thread_data.get("commentable_id", ""), + "created_at": thread_data.get("created_at"), + "updated_at": thread_data.get("updated_at"), + "is_deleted": True, + "deleted_at": thread_data.get("deleted_at"), + "deleted_by": deleted_by_username, + "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, + "thread_type": thread_data.get("thread_type", "discussion"), + "anonymous": thread_data.get("anonymous", False), + "anonymous_to_peers": thread_data.get("anonymous_to_peers", False), + "vote_count": thread_data.get("vote_count", 0), + "comment_count": thread_data.get("comment_count", 0), + } + + +def _process_deleted_comment(comment_data, get_user_label_fn, usernames_set): + """ + Process a single deleted comment into the standardized content item format. + + Args: + comment_data: Raw comment data from forum API + get_user_label_fn: Function to get user labels by user ID + usernames_set: Set to collect usernames for profile image fetch (modified in-place) + + Returns: + dict: Formatted content item for the comment + """ + author_username = comment_data.get("author_username", "") + deleted_by_id = comment_data.get("deleted_by") + deleted_by_username = None + + # Get deleted_by username + if deleted_by_id: + try: + deleted_user = User.objects.get(id=int(deleted_by_id)) + deleted_by_username = deleted_user.username + usernames_set.add(deleted_by_username) + except (User.DoesNotExist, ValueError): + pass + + if author_username: + usernames_set.add(author_username) + + # Determine if this is a response (depth=0) or comment (depth>0) + depth = comment_data.get("depth", 0) + comment_type = "response" if depth == 0 else "comment" + + # Get parent thread title for context + thread_id = comment_data.get("comment_thread_id", "") + thread_title = "" + if thread_id: + try: + parent_thread = Thread(id=thread_id).retrieve() + thread_title = parent_thread.get("title", "") + except Exception: # pylint: disable=broad-exception-caught + pass + + # Strip HTML tags from preview + body_text = comment_data.get("body", "") + preview_text = strip_tags(body_text)[:100] if body_text else "" + + comment_id = comment_data.get("_id", comment_data.get("id")) + return { + "id": str(comment_id) + "-comment", + "type": comment_type, + "body": body_text, + "preview_body": preview_text, + "title": thread_title, # Use parent thread title for comments/responses + "course_id": comment_data.get("course_id", ""), + "author": author_username, + "author_id": comment_data.get("author_id", ""), + "author_label": get_user_label_fn(comment_data.get("author_id")), + "comment_thread_id": str(thread_id), + "thread_title": thread_title, + "parent_id": ( + str(comment_data.get("parent_id", "")) + if comment_data.get("parent_id") + else None + ), + "created_at": comment_data.get("created_at"), + "updated_at": comment_data.get("updated_at"), + "is_deleted": True, + "deleted_at": comment_data.get("deleted_at"), + "deleted_by": deleted_by_username, + "deleted_by_label": get_user_label_fn(deleted_by_id) if deleted_by_id else None, + "depth": depth, + "anonymous": comment_data.get("anonymous", False), + "anonymous_to_peers": comment_data.get("anonymous_to_peers", False), + "endorsed": comment_data.get("endorsed", False), + "vote_count": comment_data.get("vote_count", 0), + } + + +def _add_user_profiles_to_content(deleted_content, usernames_set, request): + """ + Fetch user profile images and add them to each content item. + + Args: + deleted_content: List of content items (modified in-place) + usernames_set: Set of usernames to fetch profile images for + request: Django request object for getting profile images + """ + # Add profile images for all users + username_profile_dict = _get_user_profile_dict( + request, usernames=",".join(usernames_set) + ) + + # Add users dict with profile images to each item + for item in deleted_content: + users_dict = {} + + # Add author profile + author_username = item.get("author") + if author_username and author_username in username_profile_dict: + users_dict[author_username] = _user_profile( + username_profile_dict[author_username] + ) + + # Add deleted_by profile + deleted_by_username = item.get("deleted_by") + if deleted_by_username and deleted_by_username in username_profile_dict: + users_dict[deleted_by_username] = _user_profile( + username_profile_dict[deleted_by_username] + ) + + item["users"] = users_dict + + +def get_deleted_content_for_course( + request, + course_id, + content_type=None, + page=1, + per_page=20, + author_id=None +): + """ + Retrieve all deleted content (threads, comments) for a course. + + Args: + request: The django request object for getting user profile images + course_id (str): Course identifier + content_type (str, optional): Filter by 'thread' or 'comment'. If None, returns all types. + page (int): Page number for pagination (1-based) + per_page (int): Number of items per page + author_id (str, optional): Filter by author ID + + Returns: + dict: Paginated results with deleted content including author labels and profile images + """ + + import math + + from lms.djangoapps.discussion.rest_api.utils import ( + get_course_staff_users_list, + get_course_ta_users_list, + get_moderator_users_list, + ) + + try: + # Get course and user role information for labels + course_key = CourseKey.from_string(course_id) + course = _get_course(course_key, request.user) + + course_staff_user_ids = get_course_staff_users_list(course.id) + moderator_user_ids = get_moderator_users_list(course.id) + ta_user_ids = get_course_ta_users_list(course.id) + + # Get user label function + get_user_label = _get_user_label_function( + course_staff_user_ids, moderator_user_ids, ta_user_ids + ) + + # Build query parameters for forum API + query_params = { + "course_id": course_id, + "is_deleted": True, # Only get deleted content + "page": page, + "per_page": per_page, + } + + if author_id: + query_params["author_id"] = author_id + + deleted_content = [] + total_count = 0 + usernames_set = set() # Track all usernames for profile image fetch + + # Get deleted threads + if content_type is None or content_type == "thread": + try: + deleted_threads = forum_api.get_deleted_threads_for_course( + course_id=course_id, + page=page if content_type == "thread" else 1, + per_page=per_page if content_type == "thread" else 1000, + author_id=author_id, + ) + for thread_data in deleted_threads.get("threads", []): + content_item = _process_deleted_thread( + thread_data, get_user_label, usernames_set + ) + deleted_content.append(content_item) + + if content_type == "thread": + total_count = deleted_threads.get( + "total_count", len(deleted_content) + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to get deleted threads for course %s: %s", course_id, e + ) + + # Get deleted comments + if content_type is None or content_type == "comment": + try: + deleted_comments = forum_api.get_deleted_comments_for_course( + course_id=course_id, + page=page if content_type == "comment" else 1, + per_page=per_page if content_type == "comment" else 1000, + author_id=author_id, + ) + for comment_data in deleted_comments.get("comments", []): + content_item = _process_deleted_comment( + comment_data, get_user_label, usernames_set + ) + deleted_content.append(content_item) + + if content_type == "comment": + total_count = deleted_comments.get( + "total_count", len(deleted_content) + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.warning( + "Failed to get deleted comments for course %s: %s", course_id, e + ) + + # If getting all content types, handle pagination differently + if content_type is None: + total_count = len(deleted_content) + # Sort by deletion date (most recent first) + deleted_content.sort(key=lambda x: x.get("deleted_at", ""), reverse=True) + + # Apply pagination to combined results + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + deleted_content = deleted_content[start_idx:end_idx] + + # Add profile images for all users + _add_user_profiles_to_content(deleted_content, usernames_set, request) + + # Calculate pagination info + num_pages = math.ceil(total_count / per_page) if total_count > 0 else 1 + + return { + "results": deleted_content, + "pagination": { + "next": None, # Can be computed if needed + "previous": None, # Can be computed if needed + "count": total_count, + "num_pages": num_pages, + }, + } + + except Exception as e: + log.exception("Error getting deleted content for course %s: %s", course_id, e) + raise diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 8cc7127645b2..02e86918c8b0 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -1,6 +1,7 @@ """ Discussion API forms """ + import urllib.parse from django.core.exceptions import ValidationError @@ -22,13 +23,15 @@ class UserOrdering(TextChoices): - BY_ACTIVITY = 'activity' - BY_FLAGS = 'flagged' - BY_RECENT_ACTIVITY = 'recency' + BY_ACTIVITY = "activity" + BY_FLAGS = "flagged" + BY_RECENT_ACTIVITY = "recency" + BY_DELETED = "deleted" class _PaginationForm(Form): """A form that includes pagination fields""" + page = IntegerField(required=False, min_value=1) page_size = IntegerField(required=False, min_value=1) @@ -45,6 +48,7 @@ class ThreadListGetForm(_PaginationForm): """ A form to validate query parameters in the thread list retrieval endpoint """ + EXCLUSIVE_PARAMS = ["topic_id", "text_search", "following"] course_id = CharField() @@ -58,17 +62,22 @@ class ThreadListGetForm(_PaginationForm): ) count_flagged = ExtendedNullBooleanField(required=False) flagged = ExtendedNullBooleanField(required=False) + show_deleted = ExtendedNullBooleanField(required=False) view = ChoiceField( - choices=[(choice, choice) for choice in ["unread", "unanswered", "unresponded"]], + choices=[ + (choice, choice) for choice in ["unread", "unanswered", "unresponded"] + ], required=False, ) order_by = ChoiceField( - choices=[(choice, choice) for choice in ["last_activity_at", "comment_count", "vote_count"]], - required=False + choices=[ + (choice, choice) + for choice in ["last_activity_at", "comment_count", "vote_count"] + ], + required=False, ) order_direction = ChoiceField( - choices=[(choice, choice) for choice in ["desc"]], - required=False + choices=[(choice, choice) for choice in ["desc"]], required=False ) requested_fields = MultiValueField(required=False) @@ -85,14 +94,18 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError: - raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError( + f"'{value}' is not a valid course id" + ) from e def clean_following(self): """Validate following""" value = self.cleaned_data["following"] if value is False: # lint-amnesty, pylint: disable=no-else-raise - raise ValidationError("The value of the 'following' parameter must be true.") + raise ValidationError( + "The value of the 'following' parameter must be true." + ) else: return value @@ -115,6 +128,7 @@ class ThreadActionsForm(Form): A form to handle fields in thread creation/update that require separate interactions with the comments service. """ + following = BooleanField(required=False) voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -126,17 +140,20 @@ class CommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ + thread_id = CharField() flagged = BooleanField(required=False) endorsed = ExtendedNullBooleanField(required=False) requested_fields = MultiValueField(required=False) merge_question_type_responses = BooleanField(required=False) + show_deleted = ExtendedNullBooleanField(required=False) class UserCommentListGetForm(_PaginationForm): """ A form to validate query parameters in the comment list retrieval endpoint """ + course_id = CharField() flagged = BooleanField(required=False) requested_fields = MultiValueField(required=False) @@ -146,8 +163,10 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError: - raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError( + f"'{value}' is not a valid course id" + ) from e class CommentActionsForm(Form): @@ -155,6 +174,7 @@ class CommentActionsForm(Form): A form to handle fields in comment creation/update that require separate interactions with the comments service. """ + voted = BooleanField(required=False) abuse_flagged = BooleanField(required=False) @@ -163,6 +183,7 @@ class CommentGetForm(_PaginationForm): """ A form to validate query parameters in the comment retrieval endpoint """ + requested_fields = MultiValueField(required=False) @@ -170,28 +191,34 @@ class CourseDiscussionSettingsForm(Form): """ A form to validate the fields in the course discussion settings requests. """ + course_id = CharField() def __init__(self, *args, **kwargs): - self.request_user = kwargs.pop('request_user') + self.request_user = kwargs.pop("request_user") super().__init__(*args, **kwargs) def clean_course_id(self): """Validate the 'course_id' value""" - course_id = self.cleaned_data['course_id'] + course_id = self.cleaned_data["course_id"] try: course_key = CourseKey.from_string(course_id) - self.cleaned_data['course'] = get_course_with_access(self.request_user, 'load', course_key) - self.cleaned_data['course_key'] = course_key + self.cleaned_data["course"] = get_course_with_access( + self.request_user, "load", course_key + ) + self.cleaned_data["course_key"] = course_key return course_id - except InvalidKeyError: - raise ValidationError(f"'{str(course_id)}' is not a valid course key") # lint-amnesty, pylint: disable=raise-missing-from + except InvalidKeyError as e: + raise ValidationError( + f"'{str(course_id)}' is not a valid course key" + ) from e class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): """ A form to validate the fields in the course discussion roles requests. """ + ROLE_CHOICES = ( (FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR), (FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR), @@ -199,20 +226,20 @@ class CourseDiscussionRolesForm(CourseDiscussionSettingsForm): ) rolename = ChoiceField( choices=ROLE_CHOICES, - error_messages={"invalid_choice": "Role '%(value)s' does not exist"} + error_messages={"invalid_choice": "Role '%(value)s' does not exist"}, ) def clean_rolename(self): """Validate the 'rolename' value.""" - rolename = urllib.parse.unquote(self.cleaned_data.get('rolename')) - course_id = self.cleaned_data.get('course_key') + rolename = urllib.parse.unquote(self.cleaned_data.get("rolename")) + course_id = self.cleaned_data.get("course_key") if course_id and rolename: try: role = Role.objects.get(name=rolename, course_id=course_id) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - self.cleaned_data['role'] = role + self.cleaned_data["role"] = role return rolename @@ -220,15 +247,17 @@ class TopicListGetForm(Form): """ Form for the topics API get query parameters. """ + topic_id = CharField(required=False) order_by = ChoiceField(choices=TopicOrdering.choices, required=False) def clean_topic_id(self): topic_ids = self.cleaned_data.get("topic_id", None) - return set(topic_ids.strip(',').split(',')) if topic_ids else None + return set(topic_ids.strip(",").split(",")) if topic_ids else None class CourseActivityStatsForm(_PaginationForm): """Form for validating course activity stats API query parameters""" + order_by = ChoiceField(choices=UserOrdering.choices, required=False) username = CharField(required=False) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 8a7ab16e0903..902a433dac3b 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -1,13 +1,13 @@ """ Discussion API serializers """ + import html import re - -from bs4 import BeautifulSoup from typing import Dict from urllib.parse import urlencode, urlunparse +from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -18,8 +18,12 @@ from common.djangoapps.student.models import get_user_by_username_or_email from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_lock_unlock_event, \ - track_thread_edited_event, track_comment_edited_event, track_forum_response_mark_event +from lms.djangoapps.discussion.django_comment_client.base.views import ( + track_comment_edited_event, + track_forum_response_mark_event, + track_thread_edited_event, + track_thread_lock_unlock_event, +) from lms.djangoapps.discussion.django_comment_client.utils import ( course_discussion_division_enabled, get_group_id_for_user, @@ -35,17 +39,23 @@ from lms.djangoapps.discussion.rest_api.render import render_body from lms.djangoapps.discussion.rest_api.utils import ( get_course_staff_users_list, - get_moderator_users_list, get_course_ta_users_list, + get_moderator_users_list, get_user_learner_status, ) from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.discussions.utils import get_group_names_by_id from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser -from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings +from openedx.core.djangoapps.django_comment_common.comment_client.user import ( + User as CommentClientUser, +) +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientRequestError, +) +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, +) from openedx.core.djangoapps.user_api.accounts.api import get_profile_images from openedx.core.lib.api.serializers import CourseKeyField @@ -59,6 +69,7 @@ class TopicOrdering(TextChoices): """ Enum for the available options for ordering topics. """ + COURSE_STRUCTURE = "course_structure", "Course Structure" ACTIVITY = "activity", "Activity" NAME = "name", "Name" @@ -73,16 +84,24 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) + cc_requester = CommentClientUser.from_django_user(requester).retrieve( + course_id=course.id + ) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) - has_moderation_privilege = requester.id in moderator_user_ids or requester.id in ta_user_ids or is_global_staff + has_moderation_privilege = ( + requester.id in moderator_user_ids + or requester.id in ta_user_ids + or is_global_staff + ) return { "course": course, "request": request, "thread": thread, - "discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings), + "discussion_division_enabled": course_discussion_division_enabled( + course_discussion_settings + ), "group_ids_to_names": get_group_names_by_id(course_discussion_settings), "moderator_user_ids": moderator_user_ids, "course_staff_user_ids": course_staff_user_ids, @@ -137,8 +156,8 @@ def _validate_privileged_access(context: Dict) -> bool: Returns: bool: Course exists and the user has privileged access. """ - course = context.get('course', None) - is_requester_privileged = context.get('has_moderation_privilege') + course = context.get("course", None) + is_requester_privileged = context.get("has_moderation_privilege") return course and is_requester_privileged @@ -158,7 +177,7 @@ def filter_spam_urls_from_html(html_string): patterns.append(re.compile(rf"(https?://)?{domain_pattern}", re.IGNORECASE)) for a_tag in soup.find_all("a", href=True): - href = a_tag.get('href') + href = a_tag.get("href") if href: if any(p.search(href) for p in patterns): a_tag.replace_with(a_tag.get_text(strip=True)) @@ -167,7 +186,7 @@ def filter_spam_urls_from_html(html_string): for text_node in soup.find_all(string=True): new_text = text_node for p in patterns: - new_text = p.sub('', new_text) + new_text = p.sub("", new_text) if new_text != text_node: text_node.replace_with(new_text.strip()) is_spam = True @@ -196,8 +215,14 @@ class _ContentSerializer(serializers.Serializer): anonymous = serializers.BooleanField(default=False) anonymous_to_peers = serializers.BooleanField(default=False) last_edit = serializers.SerializerMethodField(required=False) - edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code]) + edit_reason_code = serializers.CharField( + required=False, validators=[validate_edit_reason_code] + ) edit_by_label = serializers.SerializerMethodField(required=False) + is_deleted = serializers.SerializerMethodField(read_only=True) + deleted_at = serializers.SerializerMethodField(read_only=True) + deleted_by = serializers.SerializerMethodField(read_only=True) + deleted_by_label = serializers.SerializerMethodField(read_only=True) non_updatable_fields = set() @@ -219,7 +244,10 @@ def _is_user_privileged(self, user_id): Returns a boolean indicating whether the given user_id identifies a privileged user. """ - return user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] + return ( + user_id in self.context["moderator_user_ids"] + or user_id in self.context["ta_user_ids"] + ) def _is_anonymous(self, obj): """ @@ -227,13 +255,13 @@ def _is_anonymous(self, obj): the requester. """ user_id = self.context["request"].user.id - is_user_staff = user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"] - - return ( - obj["anonymous"] or - obj["anonymous_to_peers"] and not is_user_staff + is_user_staff = ( + user_id in self.context["moderator_user_ids"] + or user_id in self.context["ta_user_ids"] ) + return obj["anonymous"] or obj["anonymous_to_peers"] and not is_user_staff + def get_author(self, obj): """ Returns the author's username, or None if the content is anonymous. @@ -250,10 +278,9 @@ def _get_user_label(self, user_id): is_ta = user_id in self.context["ta_user_ids"] return ( - "Staff" if is_staff else - "Moderator" if is_moderator else - "Community TA" if is_ta else - None + "Staff" + if is_staff + else "Moderator" if is_moderator else "Community TA" if is_ta else None ) def _get_user_label_from_username(self, username): @@ -303,7 +330,9 @@ def get_rendered_body(self, obj): """ if self._rendered_body is None: self._rendered_body = render_body(obj["body"]) - self._rendered_body, is_spam = filter_spam_urls_from_html(self._rendered_body) + self._rendered_body, is_spam = filter_spam_urls_from_html( + self._rendered_body + ) if is_spam and settings.CONTENT_FOR_SPAM_POSTS: self._rendered_body = settings.CONTENT_FOR_SPAM_POSTS return self._rendered_body @@ -315,8 +344,9 @@ def get_abuse_flagged(self, obj): """ total_abuse_flaggers = len(obj.get("abuse_flaggers", [])) return ( - self.context["has_moderation_privilege"] and total_abuse_flaggers > 0 or - self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) + self.context["has_moderation_privilege"] + and total_abuse_flaggers > 0 + or self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", []) ) def get_voted(self, obj): @@ -349,7 +379,7 @@ def get_last_edit(self, obj): Returns information about the last edit for this content for privileged users. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None edit_history = obj.get("edit_history") @@ -365,12 +395,57 @@ def get_edit_by_label(self, obj): """ Returns the role label for the last edit user. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) is_user_privileged = _validate_privileged_access(self.context) edit_history = obj.get("edit_history") if (is_user_author or is_user_privileged) and edit_history: last_edit = edit_history[-1] - return self._get_user_label_from_username(last_edit.get('editor_username')) + return self._get_user_label_from_username(last_edit.get("editor_username")) + + def get_is_deleted(self, obj): + """ + Returns the is_deleted status for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + return obj.get("is_deleted", False) + + def get_deleted_at(self, obj): + """ + Returns the deletion timestamp for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + return obj.get("deleted_at") + + def get_deleted_by(self, obj): + """ + Returns the username of the user who deleted this content for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + deleted_by_id = obj.get("deleted_by") + if deleted_by_id: + try: + user = User.objects.get(id=int(deleted_by_id)) + return user.username + except (User.DoesNotExist, ValueError): + return None + return None + + def get_deleted_by_label(self, obj): + """ + Returns the role label for the user who deleted this content for privileged users only. + """ + if not _validate_privileged_access(self.context): + return None + deleted_by_id = obj.get("deleted_by") + if deleted_by_id: + try: + return self._get_user_label(int(deleted_by_id)) + except (ValueError, TypeError): + return None + return None class ThreadSerializer(_ContentSerializer): @@ -381,13 +456,15 @@ class ThreadSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Thread's __getattr__. """ + course_id = serializers.CharField() - topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank]) + topic_id = serializers.CharField( + source="commentable_id", validators=[validate_not_blank] + ) group_id = serializers.IntegerField(required=False, allow_null=True) group_name = serializers.SerializerMethodField() type = serializers.ChoiceField( - source="thread_type", - choices=[(val, val) for val in ["discussion", "question"]] + source="thread_type", choices=[(val, val) for val in ["discussion", "question"]] ) preview_body = serializers.SerializerMethodField() abuse_flagged_count = serializers.SerializerMethodField(required=False) @@ -402,8 +479,12 @@ class ThreadSerializer(_ContentSerializer): non_endorsed_comment_list_url = serializers.SerializerMethodField() read = serializers.BooleanField(required=False) has_endorsed = serializers.BooleanField(source="endorsed", read_only=True) - response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False) - close_reason_code = serializers.CharField(required=False, validators=[validate_close_reason_code]) + response_count = serializers.IntegerField( + source="resp_total", read_only=True, required=False + ) + close_reason_code = serializers.CharField( + required=False, validators=[validate_close_reason_code] + ) close_reason = serializers.SerializerMethodField() closed_by = serializers.SerializerMethodField() closed_by_label = serializers.SerializerMethodField(required=False) @@ -449,9 +530,8 @@ def get_comment_list_url(self, obj, endorsed=None): Returns the URL to retrieve the thread's comments, optionally including the endorsed query parameter. """ - if ( - (obj["thread_type"] == "question" and endorsed is None) or - (obj["thread_type"] == "discussion" and endorsed is not None) + if (obj["thread_type"] == "question" and endorsed is None) or ( + obj["thread_type"] == "discussion" and endorsed is not None ): return None path = reverse("comment-list") @@ -495,13 +575,17 @@ def get_preview_body(self, obj): """ Returns a cleaned version of the thread's body to display in a preview capacity. """ - return strip_tags(self.get_rendered_body(obj)).replace('\n', ' ').replace(' ', ' ') + return ( + strip_tags(self.get_rendered_body(obj)) + .replace("\n", " ") + .replace(" ", " ") + ) def get_close_reason(self, obj): """ Returns the reason for which the thread was closed. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if not (_validate_privileged_access(self.context) or is_user_author): return None reason_code = obj.get("close_reason_code") @@ -512,7 +596,7 @@ def get_closed_by(self, obj): Returns the username of the moderator who closed this thread, only to other privileged users and author. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if _validate_privileged_access(self.context) or is_user_author: return obj.get("closed_by") @@ -520,7 +604,7 @@ def get_closed_by_label(self, obj): """ Returns the role label for the user who closed the post. """ - is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_author = str(obj["user_id"]) == str(self.context["request"].user.id) if is_user_author or _validate_privileged_access(self.context): return self._get_user_label_from_username(obj.get("closed_by")) @@ -535,18 +619,31 @@ def update(self, instance, validated_data): requesting_user_id = self.context["cc_requester"]["id"] if key == "closed" and val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event(self.context['request'], self.context['course'], - instance, validated_data.get('close_reason_code')) + track_thread_lock_unlock_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("close_reason_code"), + ) if key == "closed" and not val: instance["closing_user_id"] = requesting_user_id - track_thread_lock_unlock_event(self.context['request'], self.context['course'], - instance, validated_data.get('close_reason_code'), locked=False) + track_thread_lock_unlock_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("close_reason_code"), + locked=False, + ) if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_thread_edited_event(self.context['request'], self.context['course'], - instance, validated_data.get('edit_reason_code')) + track_thread_edited_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("edit_reason_code"), + ) instance.save() return instance @@ -559,6 +656,7 @@ class CommentSerializer(_ContentSerializer): not had retrieve() called, because of the interaction between DRF's attempts at introspection and Comment's __getattr__. """ + thread_id = serializers.CharField() parent_id = serializers.CharField(required=False, allow_null=True) endorsed = serializers.BooleanField(required=False) @@ -573,7 +671,7 @@ class CommentSerializer(_ContentSerializer): non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS def __init__(self, *args, **kwargs): - remove_fields = kwargs.pop('remove_fields', None) + remove_fields = kwargs.pop("remove_fields", None) super().__init__(*args, **kwargs) if remove_fields: @@ -595,8 +693,8 @@ def get_endorsed_by(self, obj): # Avoid revealing the identity of an anonymous non-staff question # author who has endorsed a comment in the thread if not ( - self._is_anonymous(self.context["thread"]) and - not self._is_user_privileged(endorser_id) + self._is_anonymous(self.context["thread"]) + and not self._is_user_privileged(endorser_id) ): return User.objects.get(id=endorser_id).username return None @@ -638,7 +736,7 @@ def to_representation(self, data): # Django Rest Framework v3 no longer includes None values # in the representation. To maintain the previous behavior, # we do this manually instead. - if 'parent_id' not in data: + if "parent_id" not in data: data["parent_id"] = None return data @@ -680,7 +778,7 @@ def create(self, validated_data): comment = Comment( course_id=self.context["thread"]["course_id"], user_id=self.context["cc_requester"]["id"], - **validated_data + **validated_data, ) comment.save() return comment @@ -693,12 +791,18 @@ def update(self, instance, validated_data): # endorsement_user_id on update requesting_user_id = self.context["cc_requester"]["id"] if key == "endorsed": - track_forum_response_mark_event(self.context['request'], self.context['course'], instance, val) + track_forum_response_mark_event( + self.context["request"], self.context["course"], instance, val + ) instance["endorsement_user_id"] = requesting_user_id if key == "body" and val: instance["editing_user_id"] = requesting_user_id - track_comment_edited_event(self.context['request'], self.context['course'], - instance, validated_data.get('edit_reason_code')) + track_comment_edited_event( + self.context["request"], + self.context["course"], + instance, + validated_data.get("edit_reason_code"), + ) instance.save() return instance @@ -708,6 +812,7 @@ class DiscussionTopicSerializer(serializers.Serializer): """ Serializer for DiscussionTopic """ + id = serializers.CharField(read_only=True) # pylint: disable=invalid-name name = serializers.CharField(read_only=True) thread_list_url = serializers.CharField(read_only=True) @@ -737,10 +842,11 @@ class DiscussionTopicSerializerV2(serializers.Serializer): """ Serializer for new style topics. """ + id = serializers.CharField( # pylint: disable=invalid-name read_only=True, source="external_id", - help_text="Provider-specific unique id for the topic" + help_text="Provider-specific unique id for the topic", ) usage_key = serializers.CharField( read_only=True, @@ -764,10 +870,13 @@ def get_thread_counts(self, obj: DiscussionTopicLink) -> Dict[str, int]: """ Get thread counts from provided context """ - return self.context['thread_counts'].get(obj.external_id, { - "discussion": 0, - "question": 0, - }) + return self.context["thread_counts"].get( + obj.external_id, + { + "discussion": 0, + "question": 0, + }, + ) class DiscussionRolesSerializer(serializers.Serializer): @@ -775,10 +884,7 @@ class DiscussionRolesSerializer(serializers.Serializer): Serializer for course discussion roles. """ - ACTION_CHOICES = ( - ('allow', 'allow'), - ('revoke', 'revoke') - ) + ACTION_CHOICES = (("allow", "allow"), ("revoke", "revoke")) action = serializers.ChoiceField(ACTION_CHOICES) user_id = serializers.CharField() @@ -799,14 +905,16 @@ def validate_user_id(self, user_id): self.user = get_user_by_username_or_email(user_id) return user_id except User.DoesNotExist as err: - raise ValidationError(f"'{user_id}' is not a valid student identifier") from err + raise ValidationError( + f"'{user_id}' is not a valid student identifier" + ) from err def validate(self, attrs): """Validate the data at an object level.""" # Store the user object to avoid fetching it again. - if hasattr(self, 'user'): - attrs['user'] = self.user + if hasattr(self, "user"): + attrs["user"] = self.user return attrs def create(self, validated_data): @@ -824,6 +932,7 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): """ Serializer for course discussion roles member data. """ + username = serializers.CharField() email = serializers.EmailField() first_name = serializers.CharField() @@ -832,7 +941,7 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.course_discussion_settings = self.context['course_discussion_settings'] + self.course_discussion_settings = self.context["course_discussion_settings"] def get_group_name(self, instance): """Return the group name of the user.""" @@ -855,6 +964,7 @@ class DiscussionRolesListSerializer(serializers.Serializer): """ Serializer for course discussion roles member list. """ + course_id = serializers.CharField() results = serializers.SerializerMethodField() division_scheme = serializers.SerializerMethodField() @@ -862,15 +972,17 @@ class DiscussionRolesListSerializer(serializers.Serializer): def get_results(self, obj): """Return the nested serializer data representing a list of member users.""" context = { - 'course_id': obj['course_id'], - 'course_discussion_settings': self.context['course_discussion_settings'] + "course_id": obj["course_id"], + "course_discussion_settings": self.context["course_discussion_settings"], } - serializer = DiscussionRolesMemberSerializer(obj['users'], context=context, many=True) + serializer = DiscussionRolesMemberSerializer( + obj["users"], context=context, many=True + ) return serializer.data def get_division_scheme(self, obj): # pylint: disable=unused-argument """Return the division scheme for the course.""" - return self.context['course_discussion_settings'].division_scheme + return self.context["course_discussion_settings"].division_scheme def create(self, validated_data): """ @@ -887,9 +999,13 @@ class UserStatsSerializer(serializers.Serializer): """ Serializer for course user stats. """ + threads = serializers.IntegerField() replies = serializers.IntegerField() responses = serializers.IntegerField() + deleted_threads = serializers.IntegerField(required=False, default=0) + deleted_replies = serializers.IntegerField(required=False, default=0) + deleted_responses = serializers.IntegerField(required=False, default=0) active_flags = serializers.IntegerField() inactive_flags = serializers.IntegerField() username = serializers.CharField() @@ -907,27 +1023,36 @@ class BlackoutDateSerializer(serializers.Serializer): """ Serializer for blackout dates. """ - start = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the start of the blackout period") - end = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the end of the blackout period") + + start = serializers.DateTimeField( + help_text="The ISO 8601 timestamp for the start of the blackout period" + ) + end = serializers.DateTimeField( + help_text="The ISO 8601 timestamp for the end of the blackout period" + ) class ReasonCodeSeralizer(serializers.Serializer): """ Serializer for reason codes. """ + code = serializers.CharField(help_text="A code for the an edit or close reason") - label = serializers.CharField(help_text="A user-friendly name text for the close or edit reason") + label = serializers.CharField( + help_text="A user-friendly name text for the close or edit reason" + ) class CourseMetadataSerailizer(serializers.Serializer): """ Serializer for course metadata. """ + id = CourseKeyField(help_text="The identifier of the course") blackouts = serializers.ListField( child=BlackoutDateSerializer(), help_text="A list of objects representing blackout periods " - "(during which discussions are read-only except for privileged users)." + "(during which discussions are read-only except for privileged users).", ) thread_list_url = serializers.URLField( help_text="The URL of the list of all threads in the course.", @@ -935,7 +1060,9 @@ class CourseMetadataSerailizer(serializers.Serializer): following_thread_list_url = serializers.URLField( help_text="thread_list_url with parameter following=True", ) - topics_url = serializers.URLField(help_text="The URL of the topic listing for the course.") + topics_url = serializers.URLField( + help_text="The URL of the topic listing for the course." + ) allow_anonymous = serializers.BooleanField( help_text="A boolean indicating whether anonymous posts are allowed or not.", ) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index cd725a3513dc..5773fbbc83b0 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,32 +1,36 @@ """ Contain celery tasks """ + import logging from celery import shared_task from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute -from opaque_keys.edx.locator import CourseKey from eventtracking import tracker +from opaque_keys.edx.locator import CourseKey -from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.track import segment from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.rest_api.discussions_notifications import ( + DiscussionNotificationSender, +) from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners from openedx.core.djangoapps.django_comment_common.comment_client import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS - User = get_user_model() log = logging.getLogger(__name__) @shared_task @set_code_owner_attribute -def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False): +def send_thread_created_notification( + thread_id, course_key_str, user_id, notify_all_learners=False +): """ Send notification when a new thread is created """ @@ -40,17 +44,21 @@ def send_thread_created_notification(thread_id, course_key_str, user_id, notify_ is_course_staff = CourseStaffRole(course_key).has_user(user) is_course_admin = CourseInstructorRole(course_key).has_user(user) user_roles = get_user_role_names(user, course_key) - if not can_user_notify_all_learners(user_roles, is_course_staff, is_course_admin): + if not can_user_notify_all_learners( + user_roles, is_course_staff, is_course_admin + ): return - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) + course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) notification_sender = DiscussionNotificationSender(thread, course, user) notification_sender.send_new_thread_created_notification(notify_all_learners) @shared_task @set_code_owner_attribute -def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None): +def send_response_notifications( + thread_id, course_key_str, user_id, comment_id, parent_id=None +): """ Send notifications to users who are subscribed to the thread. """ @@ -59,8 +67,10 @@ def send_response_notifications(thread_id, course_key_str, user_id, comment_id, return thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) - course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id) + course = get_course_with_access(user, "load", course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender( + thread, course, user, parent_id, comment_id + ) notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() @@ -69,7 +79,9 @@ def send_response_notifications(thread_id, course_key_str, user_id, comment_id, @shared_task @set_code_owner_attribute -def send_response_endorsed_notifications(thread_id, response_id, course_key_str, endorsed_by): +def send_response_endorsed_notifications( + thread_id, response_id, course_key_str, endorsed_by +): """ Send notifications when a response is marked answered/ endorsed """ @@ -80,8 +92,10 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, response = Comment(id=response_id).retrieve() creator = User.objects.get(id=response.user_id) endorser = User.objects.get(id=endorsed_by) - course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id) + course = get_course_with_access(creator, "load", course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender( + thread, course, creator, comment_id=response_id + ) # skip sending notification to author of thread if they are the same as the author of the response if response.user_id != thread.user_id: # sends notification to author of thread @@ -99,15 +113,63 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None): Deletes all posts for user in a course. """ event_data = event_data or {} - log.info(f"<> Deleting all posts for {username} in course {course_ids}") - threads_deleted = Thread.delete_user_threads(user_id, course_ids) - comments_deleted = Comment.delete_user_comments(user_id, course_ids) - log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " - f"in course {course_ids}") - event_data.update({ - "number_of_posts_deleted": threads_deleted, - "number_of_comments_deleted": comments_deleted, - }) - event_name = 'edx.discussion.bulk_delete_user_posts' + log.info( + f"<> Deleting all posts for {username} in course {course_ids}" + ) + # Get triggered_by user_id from event_data for audit trail + deleted_by_user_id = event_data.get("triggered_by_user_id") if event_data else None + threads_deleted = Thread.delete_user_threads( + user_id, course_ids, deleted_by=deleted_by_user_id + ) + comments_deleted = Comment.delete_user_comments( + user_id, course_ids, deleted_by=deleted_by_user_id + ) + log.info( + f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " + f"in course {course_ids}" + ) + event_data.update( + { + "number_of_posts_deleted": threads_deleted, + "number_of_comments_deleted": comments_deleted, + } + ) + event_name = "edx.discussion.bulk_delete_user_posts" + tracker.emit(event_name, event_data) + segment.track("None", event_name, event_data) + + +@shared_task +@set_code_owner_attribute +def restore_course_post_for_user(user_id, username, course_ids, event_data=None): + """ + Restores all soft-deleted posts for user in a course by setting is_deleted=False. + """ + event_data = event_data or {} + log.info( + "<> Restoring all posts for %s in course %s", username, course_ids + ) + # Get triggered_by user_id from event_data for audit trail + restored_by_user_id = event_data.get("triggered_by_user_id") if event_data else None + threads_restored = Thread.restore_user_deleted_threads( + user_id, course_ids, restored_by=restored_by_user_id + ) + comments_restored = Comment.restore_user_deleted_comments( + user_id, course_ids, restored_by=restored_by_user_id + ) + log.info( + "<> Restored %s posts and %s comments for %s in course %s", + threads_restored, + comments_restored, + username, + course_ids, + ) + event_data.update( + { + "number_of_posts_restored": threads_restored, + "number_of_comments_restored": comments_restored, + } + ) + event_name = "edx.discussion.bulk_restore_user_posts" tracker.emit(event_name, event_data) - segment.track('None', event_name, event_data) + segment.track("None", event_name, event_data) diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f102dc41f249..9753774f075c 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -9,6 +9,7 @@ from lms.djangoapps.discussion.rest_api.views import ( BulkDeleteUserPosts, + BulkRestoreUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -18,8 +19,10 @@ CourseTopicsViewV3, CourseView, CourseViewV2, + DeletedContentView, LearnerThreadView, ReplaceUsernamesView, + RestoreContent, RetireUserView, ThreadViewSet, UploadFileView, @@ -31,26 +34,22 @@ urlpatterns = [ re_path( - r"^v1/courses/{}/settings$".format( - settings.COURSE_ID_PATTERN - ), + r"^v1/courses/{}/settings$".format(settings.COURSE_ID_PATTERN), CourseDiscussionSettingsAPIView.as_view(), name="discussion_course_settings", ), re_path( - r"^v1/courses/{}/learner/$".format( - settings.COURSE_ID_PATTERN - ), + r"^v1/courses/{}/learner/$".format(settings.COURSE_ID_PATTERN), LearnerThreadView.as_view(), name="discussion_learner_threads", ), re_path( - fr"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", + rf"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", CourseActivityStatsView.as_view(), name="discussion_course_activity_stats", ), re_path( - fr"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", + rf"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", UploadFileView.as_view(), name="upload_file", ), @@ -62,36 +61,55 @@ name="discussion_course_roles", ), re_path( - fr"^v1/courses/{settings.COURSE_ID_PATTERN}", + rf"^v1/courses/{settings.COURSE_ID_PATTERN}", CourseView.as_view(), - name="discussion_course" + name="discussion_course", ), re_path( - fr"^v2/courses/{settings.COURSE_ID_PATTERN}", + rf"^v2/courses/{settings.COURSE_ID_PATTERN}", CourseViewV2.as_view(), - name="discussion_course_v2" + name="discussion_course_v2", ), - re_path(r'^v1/accounts/retire_forum/?$', RetireUserView.as_view(), name="retire_discussion_user"), - path('v1/accounts/replace_username', ReplaceUsernamesView.as_view(), name="replace_discussion_username"), re_path( - fr"^v1/course_topics/{settings.COURSE_ID_PATTERN}", + r"^v1/accounts/retire_forum/?$", + RetireUserView.as_view(), + name="retire_discussion_user", + ), + path( + "v1/accounts/replace_username", + ReplaceUsernamesView.as_view(), + name="replace_discussion_username", + ), + re_path( + rf"^v1/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsView.as_view(), - name="course_topics" + name="course_topics", ), re_path( - fr"^v2/course_topics/{settings.COURSE_ID_PATTERN}", + rf"^v2/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV2.as_view(), - name="course_topics_v2" + name="course_topics_v2", ), re_path( - fr"^v3/course_topics/{settings.COURSE_ID_PATTERN}", + rf"^v3/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV3.as_view(), - name="course_topics_v3" + name="course_topics_v3", ), re_path( - fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + rf"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", BulkDeleteUserPosts.as_view(), - name="bulk_delete_user_posts" + name="bulk_delete_user_posts", + ), + re_path( + rf"^v1/bulk_restore_user_posts/{settings.COURSE_ID_PATTERN}", + BulkRestoreUserPosts.as_view(), + name="bulk_restore_user_posts", + ), + path("v1/restore_content", RestoreContent.as_view(), name="restore_content"), + re_path( + rf"^v1/deleted_content/{settings.COURSE_ID_PATTERN}", + DeletedContentView.as_view(), + name="deleted_content", ), - path('v1/', include(ROUTER.urls)), + path("v1/", include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..3556f78562fe 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,17 +1,19 @@ """ 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 from drf_yasg import openapi from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication @@ -21,31 +23,49 @@ from rest_framework.views import APIView from rest_framework.viewsets import ViewSet -from xmodule.modulestore.django import modulestore - from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.file import store_uploaded_file from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity +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.discussion.rate_limit import is_content_creation_rate_limited 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.rest_api.tasks import ( + delete_course_post_for_user, + restore_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 -from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider +from openedx.core.djangoapps.discussions.config.waffle import ( + ENABLE_NEW_STRUCTURE_DISCUSSIONS, +) +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + Provider, +) from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + Role, +) +from openedx.core.djangoapps.user_api.accounts.permissions import ( + CanReplaceUsername, + CanRetireUser, +) from openedx.core.djangoapps.user_api.models import UserRetirementStatus -from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.authentication import ( + BearerAuthentication, + BearerAuthenticationAllowInactiveUser, +) from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from xmodule.modulestore.django import modulestore from ..rest_api.api import ( create_comment, @@ -57,10 +77,10 @@ get_course_discussion_user_stats, get_course_topics, get_course_topics_v2, + get_learner_active_thread_list, get_response_comments, get_thread, get_thread_list, - get_learner_active_thread_list, get_user_comments, get_v2_course_topics_as_v1, update_comment, @@ -88,10 +108,10 @@ from .utils import ( create_blocks_params, create_topics_v3_structure, - is_captcha_enabled, - verify_recaptcha_token, get_course_id_from_thread_id, + is_captcha_enabled, is_only_student, + verify_recaptcha_token, ) log = logging.getLogger(__name__) @@ -107,14 +127,16 @@ class CourseView(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ) ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -126,7 +148,9 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # TODO: which class is right? # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(get_course(request, course_key)) @@ -138,14 +162,16 @@ class CourseViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ) ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -156,7 +182,9 @@ def get(self, request, course_id): """ course_key = CourseKey.from_string(course_id) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(get_course(request, course_key, False)) @@ -221,14 +249,14 @@ def get(self, request, course_key_string): form_query_string = CourseActivityStatsForm(request.query_params) if not form_query_string.is_valid(): raise ValidationError(form_query_string.errors) - order_by = form_query_string.cleaned_data.get('order_by', None) + order_by = form_query_string.cleaned_data.get("order_by", None) order_by = UserOrdering(order_by) if order_by else None - username_search_string = form_query_string.cleaned_data.get('username', None) + username_search_string = form_query_string.cleaned_data.get("username", None) data = get_course_discussion_user_stats( request, course_key_string, - form_query_string.cleaned_data['page'], - form_query_string.cleaned_data['page_size'], + form_query_string.cleaned_data["page"], + form_query_string.cleaned_data["page_size"], order_by, username_search_string, ) @@ -268,19 +296,17 @@ def get(self, request, course_id): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - topic_ids = self.request.GET.get('topic_id') - topic_ids = set(topic_ids.strip(',').split(',')) if topic_ids else None + topic_ids = self.request.GET.get("topic_id") + topic_ids = set(topic_ids.strip(",").split(",")) if topic_ids else None with modulestore().bulk_operations(course_key): configuration = DiscussionsConfiguration.get(context_key=course_key) provider = configuration.provider_type # This will be removed when mobile app will support new topic structure - new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled(course_key) + new_structure_enabled = ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled( + course_key + ) if provider == Provider.OPEN_EDX and new_structure_enabled: - response = get_v2_course_topics_as_v1( - request, - course_key, - topic_ids - ) + response = get_v2_course_topics_as_v1(request, course_key, topic_ids) else: response = get_course_topics( request, @@ -288,7 +314,9 @@ def get(self, request, course_id): topic_ids, ) # Record user activity for tracking progress towards a user's course goals (for mobile app) - UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True) + UserActivity.record_user_activity( + request.user, course_key, request=request, only_if_mobile_app=True + ) return Response(response) @@ -304,17 +332,17 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): @apidocs.schema( parameters=[ apidocs.string_parameter( - 'course_id', + "course_id", apidocs.ParameterLocation.PATH, description="Course ID", ), apidocs.string_parameter( - 'topic_id', + "topic_id", apidocs.ParameterLocation.QUERY, description="Comma-separated list of topic ids to filter", ), openapi.Parameter( - 'order_by', + "order_by", apidocs.ParameterLocation.QUERY, required=False, type=openapi.TYPE_STRING, @@ -327,7 +355,7 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): 401: "The requester is not authenticated.", 403: "The requester cannot access the specified course.", 404: "The requested course does not exist.", - } + }, ) def get(self, request, course_id): """ @@ -348,7 +376,7 @@ def get(self, request, course_id): course_key, request.user, form_query_params.cleaned_data["topic_id"], - form_query_params.cleaned_data["order_by"] + form_query_params.cleaned_data["order_by"], ) return Response(response) @@ -416,17 +444,17 @@ def get(self, request, course_id): blocks_params = create_blocks_params(course_usage_key, request.user) blocks = get_blocks( request, - blocks_params['usage_key'], - blocks_params['user'], - blocks_params['depth'], - blocks_params['nav_depth'], - blocks_params['requested_fields'], - blocks_params['block_counts'], - blocks_params['student_view_data'], - blocks_params['return_type'], - blocks_params['block_types_filter'], + blocks_params["usage_key"], + blocks_params["user"], + blocks_params["depth"], + blocks_params["nav_depth"], + blocks_params["requested_fields"], + blocks_params["block_counts"], + blocks_params["student_view_data"], + blocks_params["return_type"], + blocks_params["block_types_filter"], hide_access_denials=False, - )['blocks'] + )["blocks"] topics = create_topics_v3_structure(blocks, topics) return Response(topics) @@ -627,8 +655,12 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ + lookup_field = "thread_id" - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) def list(self, request): """ @@ -641,7 +673,10 @@ class docstring. # Record user activity for tracking progress towards a user's course goals (for mobile app) UserActivity.record_user_activity( - request.user, form.cleaned_data["course_id"], request=request, only_if_mobile_app=True + request.user, + form.cleaned_data["course_id"], + request=request, + only_if_mobile_app=True, ) return get_thread_list( @@ -660,14 +695,15 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], + form.cleaned_data["show_deleted"], ) def retrieve(self, request, thread_id=None): """ Implements the GET method for thread ID """ - requested_fields = request.GET.get('requested_fields') - course_id = request.GET.get('course_id') + requested_fields = request.GET.get("requested_fields") + course_id = request.GET.get("course_id") return Response(get_thread(request, thread_id, requested_fields, course_id)) def create(self, request): @@ -681,21 +717,28 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response( + "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS + ) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get('captcha_token') + captcha_token = request.data.get("captcha_token") if not captcha_token: - raise ValidationError({'captcha_token': 'This field is required.'}) + 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."}) + 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) + data.pop("captcha_token", None) return Response(create_thread(request, data)) def partial_update(self, request, thread_id): @@ -762,24 +805,27 @@ def get(self, request, course_id=None): Implements the GET method as described in the class docstring. """ course_key = CourseKey.from_string(course_id) - page_num = request.GET.get('page', 1) - threads_per_page = request.GET.get('page_size', 10) - count_flagged = request.GET.get('count_flagged', False) - thread_type = request.GET.get('thread_type') - order_by = request.GET.get('order_by') + page_num = request.GET.get("page", 1) + threads_per_page = request.GET.get("page_size", 10) + count_flagged = request.GET.get("count_flagged", False) + thread_type = request.GET.get("thread_type") + order_by = request.GET.get("order_by") order_by_mapping = { "last_activity_at": "activity", "comment_count": "comments", - "vote_count": "votes" + "vote_count": "votes", } - order_by = order_by_mapping.get(order_by, 'activity') - post_status = request.GET.get('status', None) + order_by = order_by_mapping.get(order_by, "activity") + post_status = request.GET.get("status", None) + show_deleted = request.GET.get("show_deleted", "false").lower() == "true" discussion_id = None - username = request.GET.get('username', None) + username = request.GET.get("username", None) user = get_object_or_404(User, username=username) group_id = None try: - group_id = get_group_id_for_comments_service(request, course_key, discussion_id) + group_id = get_group_id_for_comments_service( + request, course_key, discussion_id + ) except ValueError: pass @@ -792,14 +838,17 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, + "show_deleted": show_deleted, } if post_status: - if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: - raise ValidationError({ - "status": [ - f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" - ] - }) + if post_status not in ["flagged", "unanswered", "unread", "unresponded"]: + raise ValidationError( + { + "status": [ + f"Invalid value. '{post_status}' must be 'flagged', 'unanswered', 'unread' or 'unresponded" + ] + } + ) query_params[post_status] = True return get_learner_active_thread_list(request, course_key, query_params) @@ -968,8 +1017,12 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): No content is returned for a DELETE request """ + lookup_field = "comment_id" - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) def list(self, request): """ @@ -1010,7 +1063,8 @@ def list_by_thread(self, request): form.cleaned_data["page_size"], form.cleaned_data["flagged"], form.cleaned_data["requested_fields"], - form.cleaned_data["merge_question_type_responses"] + form.cleaned_data["merge_question_type_responses"], + form.cleaned_data["show_deleted"], ) def list_by_user(self, request): @@ -1057,21 +1111,28 @@ class docstring. course_key = CourseKey.from_string(course_key_str) if is_content_creation_rate_limited(request, course_key=course_key): - return Response("Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response( + "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS + ) if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get('captcha_token') + captcha_token = request.data.get("captcha_token") if not captcha_token: - raise ValidationError({'captcha_token': 'This field is required.'}) + 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."}) + 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) + data.pop("captcha_token", None) return Response(create_comment(request, data)) def destroy(self, request, comment_id): @@ -1147,8 +1208,11 @@ def post(self, request, course_id): unique_file_name = f"{course_id}/{thread_key}/{uuid.uuid4()}" try: file_storage, stored_file_name = store_uploaded_file( - request, "uploaded_file", cc_settings.ALLOWED_UPLOAD_FILE_TYPES, - unique_file_name, max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, + request, + "uploaded_file", + cc_settings.ALLOWED_UPLOAD_FILE_TYPES, + unique_file_name, + max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE, ) except ValueError as err: raise BadRequest("no `uploaded_file` was provided") from err @@ -1189,10 +1253,12 @@ def post(self, request): """ Implements the retirement endpoint. """ - username = request.data['username'] + username = request.data["username"] try: - retirement = UserRetirementStatus.get_retirement_for_retirement_action(username) + retirement = UserRetirementStatus.get_retirement_for_retirement_action( + username + ) cc_user = comment_client.User.from_django_user(retirement.user) # Send the retired username to the forums service, as the service cannot generate @@ -1247,7 +1313,9 @@ def post(self, request): for username_pair in username_mappings: current_username = list(username_pair.keys())[0] new_username = list(username_pair.values())[0] - successfully_replaced = self._replace_username(current_username, new_username) + successfully_replaced = self._replace_username( + current_username, new_username + ) if successfully_replaced: successful_replacements.append({current_username: new_username}) else: @@ -1257,8 +1325,8 @@ def post(self, request): status=status.HTTP_200_OK, data={ "successful_replacements": successful_replacements, - "failed_replacements": failed_replacements - } + "failed_replacements": failed_replacements, + }, ) def _replace_username(self, current_username, new_username): @@ -1304,7 +1372,7 @@ def _replace_username(self, current_username, new_username): return True def _has_valid_schema(self, post_data): - """ Verifies the data is a list of objects with a single key:value pair """ + """Verifies the data is a list of objects with a single key:value pair""" if not isinstance(post_data, list): return False for obj in post_data: @@ -1364,12 +1432,16 @@ class CourseDiscussionSettingsAPIView(DeveloperErrorViewMixin, APIView): * available_division_schemes: A list of available division schemes for the course. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, ) - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) permission_classes = (permissions.IsAuthenticated, IsStaffOrAdmin) def _get_request_kwargs(self, course_id): @@ -1385,14 +1457,14 @@ def get(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course_key = form.cleaned_data['course_key'] - course = form.cleaned_data['course'] + course_key = form.cleaned_data["course_key"] + course = form.cleaned_data["course"] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - 'course': course, - 'settings': discussion_settings, + "course": course, + "settings": discussion_settings, }, partial=True, ) @@ -1411,15 +1483,15 @@ def patch(self, request, course_id): if not form.is_valid(): raise ValidationError(form.errors) - course = form.cleaned_data['course'] - course_key = form.cleaned_data['course_key'] + course = form.cleaned_data["course"] + course_key = form.cleaned_data["course_key"] discussion_settings = CourseDiscussionSettings.get(course_key) serializer = DiscussionSettingsSerializer( discussion_settings, context={ - 'course': course, - 'settings': discussion_settings, + "course": course, + "settings": discussion_settings, }, data=request.data, partial=True, @@ -1488,6 +1560,7 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): * division_scheme: The division scheme used by the course. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -1508,11 +1581,13 @@ def get(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data['course_key'] - role = form.cleaned_data['role'] + course_id = form.cleaned_data["course_key"] + role = form.cleaned_data["role"] - data = {'course_id': course_id, 'users': role.users.all()} - context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} + data = {"course_id": course_id, "users": role.users.all()} + context = { + "course_discussion_settings": CourseDiscussionSettings.get(course_id) + } serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1526,23 +1601,25 @@ def post(self, request, course_id, rolename): if not form.is_valid(): raise ValidationError(form.errors) - course_id = form.cleaned_data['course_key'] - rolename = form.cleaned_data['rolename'] + course_id = form.cleaned_data["course_key"] + rolename = form.cleaned_data["rolename"] serializer = DiscussionRolesSerializer(data=request.data) if not serializer.is_valid(): raise ValidationError(serializer.errors) - action = serializer.validated_data['action'] - user = serializer.validated_data['user'] + action = serializer.validated_data["action"] + user = serializer.validated_data["user"] try: update_forum_role(course_id, user, rolename, action) except Role.DoesNotExist as err: raise ValidationError(f"Role '{rolename}' does not exist") from err - role = form.cleaned_data['role'] - data = {'course_id': course_id, 'users': role.users.all()} - context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} + role = form.cleaned_data["role"] + data = {"course_id": course_id, "users": role.users.all()} + context = { + "course_discussion_settings": CourseDiscussionSettings.get(course_id) + } serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) @@ -1566,7 +1643,9 @@ class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): """ authentication_classes = ( - JwtAuthentication, BearerAuthentication, SessionAuthentication, + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, ) permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) @@ -1587,23 +1666,26 @@ def post(self, request, course_id): course_ids = [course_id] if course_or_org == "org": org_id = CourseKey.from_string(course_id).org - enrollments = CourseEnrollment.objects.filter(user=request.user).values_list('course_id', flat=True) - course_ids.extend([ - str(c_id) - for c_id in enrollments - if c_id.org == org_id - ]) + enrollments = CourseEnrollment.objects.filter( + user=request.user + ).values_list("course_id", flat=True) + course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) course_ids = list(set(course_ids)) log.info(f"<> {username} enrolled in {enrollments}") - log.info(f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}") + log.info( + f"<> Posts for {username} in {course_ids} - for {course_or_org} {course_id}" + ) comment_count = Comment.get_user_comment_count(user.id, course_ids) thread_count = Thread.get_user_threads_count(user.id, course_ids) - log.info(f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}") + log.info( + f"<> {username} in {course_ids} - Count thread {thread_count}, comment {comment_count}" + ) if execute_task: event_data = { "triggered_by": request.user.username, + "triggered_by_user_id": str(request.user.id), "username": username, "course_or_org": course_or_org, "course_key": course_id, @@ -1613,5 +1695,256 @@ def post(self, request, course_id): ) return Response( {"comment_count": comment_count, "thread_count": thread_count}, - status=status.HTTP_202_ACCEPTED + status=status.HTTP_202_ACCEPTED, + ) + + +class RestoreContent(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can restore individual soft-deleted threads, comments, or responses. + + **Example Requests**: + POST /api/discussion/v1/restore_content + Request Body: + { + "content_type": "thread", // "thread", "comment", or "response" + "content_id": "thread_id_or_comment_id", + "course_id": "course-v1:edX+DemoX+Demo_Course" + } + + **Example Response**: + {"success": true, "message": "Content restored successfully"} + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request): + """ + Implements the restore individual content endpoint. + """ + content_type = request.data.get("content_type") + content_id = request.data.get("content_id") + course_id = request.data.get("course_id") + + if not all([content_type, content_id, course_id]): + raise BadRequest("content_type, content_id, and course_id are required.") + + if content_type not in ["thread", "comment", "response"]: + raise BadRequest("content_type must be 'thread', 'comment', or 'response'.") + + restored_by_user_id = str(request.user.id) + + try: + if content_type == "thread": + success = Thread.restore_thread( + content_id, course_id=course_id, restored_by=restored_by_user_id + ) + else: # comment or response (both are comments in the backend) + success = Comment.restore_comment( + content_id, course_id=course_id, restored_by=restored_by_user_id + ) + + if success: + return Response( + { + "success": True, + "message": f"{content_type.capitalize()} restored successfully", + }, + status=status.HTTP_200_OK, + ) + else: + return Response( + { + "success": False, + "message": f"{content_type.capitalize()} not found or already restored", + }, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.error("Error restoring %s %s: %s", content_type, content_id, str(e)) + return Response( + { + "success": False, + "message": f"Error restoring {content_type}: {str(e)}", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class BulkRestoreUserPosts(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can restore all soft-deleted posts and comments made by a user. + It returns expected number of comments and threads that will be restored + + **Example Requests**: + POST /api/discussion/v1/bulk_restore_user_posts/{course_id} + Query Parameters: + username: The username of the user whose posts are to be restored + course_id: Course id for which posts are to be restored + execute: If True, runs restoration task + course_or_org: If 'course', restores posts in the course, if 'org', restores posts in all courses of the org + + **Example Response**: + {"comment_count": 5, "thread_count": 3} + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request, course_id): + """ + Implements the restore user posts endpoint. + """ + username = request.GET.get("username", None) + execute_task = request.GET.get("execute", "false").lower() == "true" + if (not username) or (not course_id): + raise BadRequest("username and course_id are required.") + course_or_org = request.GET.get("course_or_org", "course") + if course_or_org not in ["course", "org"]: + raise BadRequest("course_or_org must be either 'course' or 'org'.") + + user = get_object_or_404(User, username=username) + course_ids = [course_id] + if course_or_org == "org": + org_id = CourseKey.from_string(course_id).org + enrollments = CourseEnrollment.objects.filter( + user=request.user + ).values_list("course_id", flat=True) + course_ids.extend([str(c_id) for c_id in enrollments if c_id.org == org_id]) + course_ids = list(set(course_ids)) + log.info("<> %s enrolled in %s", username, enrollments) + log.info( + "<> Posts for %s in %s - for %s %s", + username, + course_ids, + course_or_org, + course_id, + ) + + comment_count = Comment.get_user_deleted_comment_count(user.id, course_ids) + thread_count = Thread.get_user_deleted_threads_count(user.id, course_ids) + log.info( + "<> %s in %s - Count thread %s, comment %s", + username, + course_ids, + thread_count, + comment_count, + ) + + if execute_task: + event_data = { + "triggered_by": request.user.username, + "triggered_by_user_id": str(request.user.id), + "username": username, + "course_or_org": course_or_org, + "course_key": course_id, + } + restore_course_post_for_user.apply_async( + args=(user.id, username, course_ids, event_data), + ) + return Response( + {"comment_count": comment_count, "thread_count": thread_count}, + status=status.HTTP_202_ACCEPTED, ) + + +class DeletedContentView(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + Retrieve all deleted content (threads, comments, responses) for a course. + This endpoint allows privileged users to fetch deleted discussion content. + + **Example Requests**: + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?content_type=thread + GET /api/discussion/v1/deleted_content/course-v1:edX+DemoX+Demo_Course?page=1&per_page=20 + + **Example Response**: + { + "results": [ + { + "id": "thread_id", + "type": "thread", + "title": "Deleted Thread Title", + "body": "Thread content...", + "course_id": "course-v1:edX+DemoX+Demo_Course", + "author_id": "user_123", + "deleted_at": "2023-11-19T10:30:00Z", + "deleted_by": "moderator_456" + } + ], + "pagination": { + "page": 1, + "per_page": 20, + "total_count": 50, + "num_pages": 3 + } + } + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthentication, + SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def get(self, request, course_id): + """ + Retrieve all deleted content for a course. + """ + try: + course_key = CourseKey.from_string(course_id) + except Exception as e: + raise BadRequest("Invalid course_id") from e + + # Get query parameters + content_type = request.GET.get( + "content_type", None + ) # 'thread', 'comment', or None for all + page = int(request.GET.get("page", 1)) + per_page = int(request.GET.get("per_page", 20)) + author_id = request.GET.get("author_id", None) + + # Validate parameters + if content_type and content_type not in ["thread", "comment"]: + raise BadRequest("content_type must be 'thread' or 'comment'") + + per_page = min(per_page, 100) # Limit to prevent excessive load + + try: + # Import here to avoid circular imports + from lms.djangoapps.discussion.rest_api.api import ( + get_deleted_content_for_course, + ) + + results = get_deleted_content_for_course( + request=request, + course_id=str(course_key), + content_type=content_type, + page=page, + per_page=per_page, + author_id=author_id, + ) + + return Response(results, status=status.HTTP_200_OK) + + except Exception as e: # pylint: disable=broad-exception-caught + logging.exception( + "Error retrieving deleted content for course %s: %s", course_id, e + ) + return Response( + {"error": "Failed to retrieve deleted content"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 8905679a45db..0f97b640b1f8 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,13 +4,15 @@ from bs4 import BeautifulSoup -from openedx.core.djangoapps.django_comment_common.comment_client import models, settings +from forum import api as forum_api +from forum.backends.mongodb.comments import Comment as ForumComment # pylint: disable=import-error +from openedx.core.djangoapps.django_comment_common.comment_client import ( + models, + settings, +) from .thread import Thread from .utils import CommentClientRequestError, get_course_key -from forum import api as forum_api -from forum.backends.mongodb.comments import Comment as ForumComment - log = logging.getLogger(__name__) @@ -18,26 +20,56 @@ class Comment(models.Model): accessible_fields = [ - 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', - 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', - 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', - 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', - 'child_count', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + "id", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "endorsed", + "parent_id", + "thread_id", + "username", + "votes", + "user_id", + "closed", + "created_at", + "updated_at", + "depth", + "at_position_list", + "type", + "commentable_id", + "abuse_flaggers", + "endorsement", + "child_count", + "edit_history", + "is_spam", + "ai_moderation_reason", + "abuse_flagged", + "is_deleted", + "deleted_at", + "deleted_by", ] updatable_fields = [ - 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', - 'user_id', 'endorsed', 'endorsement_user_id', 'edit_reason_code', - 'closing_user_id', 'editing_user_id', + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "closed", + "user_id", + "endorsed", + "endorsement_user_id", + "edit_reason_code", + "closing_user_id", + "editing_user_id", ] initializable_fields = updatable_fields - metrics_tag_fields = ['course_id', 'endorsed', 'closed'] + metrics_tag_fields = ["course_id", "endorsed", "closed"] base_url = f"{settings.PREFIX}/comments" - type = 'comment' + type = "comment" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -46,7 +78,7 @@ def __init__(self, *args, **kwargs): @property def thread(self): if not self._cached_thread: - self._cached_thread = Thread(id=self.thread_id, type='thread') + self._cached_thread = Thread(id=self.thread_id, type="thread") return self._cached_thread @property @@ -56,22 +88,22 @@ def context(self): @classmethod def url_for_comments(cls, params=None): - if params and params.get('parent_id'): - return _url_for_comment(params['parent_id']) + if params and params.get("parent_id"): + return _url_for_comment(params["parent_id"]) else: - return _url_for_thread_comments(params['thread_id']) + return _url_for_thread_comments(params["thread_id"]) @classmethod def url(cls, action, params=None): if params is None: params = {} - if action in ['post']: + if action in ["post"]: return cls.url_for_comments(params) else: return super().url(action, params) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != 'comment': + if voteable.type != "comment": raise CommentClientRequestError("Can only flag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -84,7 +116,7 @@ def flagAbuse(self, user, voteable, course_id=None): voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != 'comment': + if voteable.type != "comment": raise CommentClientRequestError("Can only unflag comments") course_key = get_course_key(self.attributes.get("course_id") or course_id) @@ -102,7 +134,7 @@ def body_text(self): """ Return the text content of the comment html body. """ - soup = BeautifulSoup(self.body, 'html.parser') + soup = BeautifulSoup(self.body, "html.parser") return soup.get_text() @classmethod @@ -114,12 +146,15 @@ def get_user_comment_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "_type": "Comment" + "is_deleted": {"$ne": True}, + "_type": "Comment", } - return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access + return ForumComment()._collection.count_documents( + query_params + ) # pylint: disable=protected-access @classmethod - def delete_user_comments(cls, user_id, course_ids): + def delete_user_comments(cls, user_id, course_ids, deleted_by=None): """ Deletes comments and responses of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -128,21 +163,76 @@ def delete_user_comments(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), + "is_deleted": {"$ne": True}, } comments_deleted = 0 comments = ForumComment().get_list(**query_params) - log.info(f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds") + log.info( + f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds" + ) for comment in comments: start_time = time.time() comment_id = comment.get("_id") course_id = comment.get("course_id") if comment_id: - forum_api.delete_comment(comment_id, course_id=course_id) + # Use forum_api.delete_comment which supports deleted_by parameter + forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg + comment_id, course_id=course_id, deleted_by=deleted_by + ) comments_deleted += 1 - log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." - f" Comment Found: {comment_id is not None}") + log.info( + f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." + f" Comment Found: {comment_id is not None}" + ) return comments_deleted + @classmethod + def get_user_deleted_comment_count(cls, user_id, course_ids): + """ + Returns count of deleted comments for user in the given course_ids. + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "Comment", + "is_deleted": True, + } + return ForumComment()._collection.count_documents( + query_params + ) # pylint: disable=protected-access + + @classmethod + def restore_user_deleted_comments(cls, user_id, course_ids, restored_by=None): + """ + Restores (undeletes) comments of user in the given course_ids by setting is_deleted=False. + """ + return forum_api.restore_user_deleted_comments( + user_id=str(user_id), + course_ids=course_ids, + course_id=course_ids[0] if course_ids else None, + restored_by=restored_by, + ) + + @classmethod + def restore_comment(cls, comment_id, course_id=None, restored_by=None): + """ + Restores an individual soft-deleted comment by setting is_deleted=False + Public method for individual comment restoration + """ + return forum_api.restore_comment( + comment_id=comment_id, course_id=course_id, restored_by=restored_by + ) + + @classmethod + def _restore_comment(cls, comment_id, course_id=None, restored_by=None): + """ + Restores a soft-deleted comment by setting is_deleted=False and clearing deletion metadata + Internal method that delegates to forum API + """ + return forum_api.restore_comment( + comment_id=comment_id, course_id=course_id, restored_by=restored_by + ) + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4544a463ed80..d97c05a96daf 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -4,24 +4,28 @@ import logging import typing as t -from .utils import CommentClientRequestError, extract, perform_request, get_course_key from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally +from openedx.core.djangoapps.discussions.config.waffle import ( + is_forum_v2_disabled_globally, + is_forum_v2_enabled, +) + +from .utils import CommentClientRequestError, extract, get_course_key, perform_request log = logging.getLogger(__name__) class Model: - accessible_fields = ['id'] - updatable_fields = ['id'] - initializable_fields = ['id'] + accessible_fields = ["id"] + updatable_fields = ["id"] + initializable_fields = ["id"] base_url = None default_retrieve_params = {} metric_tag_fields = [] - DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete'] - DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post'] + DEFAULT_ACTIONS_WITH_ID = ["get", "put", "delete"] + DEFAULT_ACTIONS_WITHOUT_ID = ["get_all", "post"] DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID def __init__(self, *args, **kwargs): @@ -29,18 +33,23 @@ def __init__(self, *args, **kwargs): self.retrieved = False def __getattr__(self, name): - if name == 'id': - return self.attributes.get('id', None) + if name == "id": + return self.attributes.get("id", None) try: return self.attributes[name] - except KeyError: + except KeyError as e: if self.retrieved or self.id is None: - raise AttributeError(f"Field {name} does not exist") # lint-amnesty, pylint: disable=raise-missing-from + raise AttributeError( + f"Field {name} does not exist" + ) from e self.retrieve() return self.__getattr__(name) def __setattr__(self, name, value): - if name == 'attributes' or name not in self.accessible_fields + self.updatable_fields: + if ( + name == "attributes" + or name not in self.accessible_fields + self.updatable_fields + ): super().__setattr__(name, value) else: self.attributes[name] = value @@ -76,7 +85,9 @@ def _retrieve(self, *args, **kwargs): if not course_id: _, course_id = is_forum_v2_enabled_for_comment(self.id) if self.type == "comment": - response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) + response = forum_api.get_parent_comment( + comment_id=self.attributes["id"], course_id=course_id + ) else: raise CommentClientRequestError("Forum v2 API call is missing") self._update_from_response(response) @@ -91,11 +102,11 @@ def _metric_tags(self): record the class name of the model. """ tags = [ - f'{self.__class__.__name__}.{attr}:{self[attr]}' + f"{self.__class__.__name__}.{attr}:{self[attr]}" for attr in self.metric_tag_fields if attr in self.attributes ] - tags.append(f'model_class:{self.__class__.__name__}') + tags.append(f"model_class:{self.__class__.__name__}") return tags @classmethod @@ -114,11 +125,11 @@ def retrieve_all(cls, params=None): The parsed JSON response from the backend. """ return perform_request( - 'get', - cls.url(action='get_all'), + "get", + cls.url(action="get_all"), params, - metric_tags=[f'model_class:{cls.__name__}'], - metric_action='model.retrieve_all', + metric_tags=[f"model_class:{cls.__name__}"], + metric_action="model.retrieve_all", ) def _update_from_response(self, response_data): @@ -128,8 +139,7 @@ def _update_from_response(self, response_data): else: log.warning( "Unexpected field {field_name} in model {model_name}".format( - field_name=k, - model_name=self.__class__.__name__ + field_name=k, model_name=self.__class__.__name__ ) ) @@ -152,7 +162,7 @@ def save(self, params=None): Invokes Forum's POST/PUT service to create/update thread """ self.before_save(self) - if self.id: # if we have id already, treat this as an update + if self.id: # if we have id already, treat this as an update response = self.handle_update(params) else: # otherwise, treat this as an insert response = self.handle_create(params) @@ -160,13 +170,21 @@ def save(self, params=None): self._update_from_response(response) self.after_save(self) - def delete(self, course_id=None): + def delete(self, course_id=None, deleted_by=None): course_key = get_course_key(self.attributes.get("course_id") or course_id) response = None if self.type == "comment": - response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + response = forum_api.delete_comment( # pylint: disable=unexpected-keyword-arg + comment_id=self.attributes["id"], + course_id=str(course_key), + deleted_by=deleted_by, + ) elif self.type == "thread": - response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + response = forum_api.delete_thread( # pylint: disable=unexpected-keyword-arg + thread_id=self.attributes["id"], + course_id=str(course_key), + deleted_by=deleted_by, + ) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") self.retrieved = True @@ -176,7 +194,7 @@ def delete(self, course_id=None): def url_with_id(cls, params=None): if params is None: params = {} - return cls.base_url + '/' + str(params['id']) + return cls.base_url + "/" + str(params["id"]) @classmethod def url_without_id(cls, params=None): @@ -187,17 +205,21 @@ def url(cls, action, params=None): if params is None: params = {} if cls.base_url is None: - raise CommentClientRequestError("Must provide base_url when using default url function") - if action not in cls.DEFAULT_ACTIONS: # lint-amnesty, pylint: disable=no-else-raise + raise CommentClientRequestError( + "Must provide base_url when using default url function" + ) + if action not in cls.DEFAULT_ACTIONS: raise ValueError( f"Invalid action {action}. The supported action must be in {str(cls.DEFAULT_ACTIONS)}" ) - elif action in cls.DEFAULT_ACTIONS_WITH_ID: + if action in cls.DEFAULT_ACTIONS_WITH_ID: try: return cls.url_with_id(params) - except KeyError: - raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from - else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now + except KeyError as e: + raise CommentClientRequestError( + f"Cannot perform action {action} without id" + ) from e + else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() def handle_update(self, params=None): @@ -306,8 +328,8 @@ def handle_create(self, params=None): try: return handlers[self.type](course_key) - except KeyError as exc: - raise CommentClientRequestError(f"Unsupported type: {self.type}") from exc + except KeyError as e: + raise CommentClientRequestError(f"Unsupported type: {self.type}") from e def handle_create_comment(self, course_id): request_data = self.initializable_attributes() @@ -319,8 +341,8 @@ def handle_create_comment(self, course_id): "anonymous": request_data.get("anonymous", False), "anonymous_to_peers": request_data.get("anonymous_to_peers", False), } - if 'endorsed' in request_data: - params['endorsed'] = request_data['endorsed'] + if "endorsed" in request_data: + params["endorsed"] = request_data["endorsed"] if parent_id := self.attributes.get("parent_id"): params["parent_comment_id"] = parent_id response = forum_api.create_child_comment(**params) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 34ccd7bf2ce6..552d6e051a49 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -5,50 +5,102 @@ import time import typing as t +from django.core.exceptions import ObjectDoesNotExist from eventtracking import tracker +from rest_framework.serializers import ValidationError -from django.core.exceptions import ObjectDoesNotExist from forum import api as forum_api -from forum.api.threads import prepare_thread_api_response -from forum.backend import get_backend -from forum.backends.mongodb.threads import CommentThread -from forum.utils import ForumV2RequestError -from rest_framework.serializers import ValidationError +from forum.api.threads import prepare_thread_api_response # pylint: disable=import-error +from forum.backend import get_backend # pylint: disable=import-error +from forum.backends.mongodb.threads import CommentThread # pylint: disable=import-error +from forum.utils import ForumV2RequestError # pylint: disable=import-error +from openedx.core.djangoapps.discussions.config.waffle import ( + is_forum_v2_disabled_globally, + is_forum_v2_enabled, +) -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally from . import models, settings, utils - log = logging.getLogger(__name__) class Thread(models.Model): # accessible_fields can be set and retrieved on the model accessible_fields = [ - 'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', - 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', - 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', - 'at_position_list', 'children', 'type', 'highlighted_title', - 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', - 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', - 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', - 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + "id", + "title", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "closed", + "tags", + "votes", + "commentable_id", + "username", + "user_id", + "created_at", + "updated_at", + "comments_count", + "unread_comments_count", + "at_position_list", + "children", + "type", + "highlighted_title", + "highlighted_body", + "endorsed", + "read", + "group_id", + "group_name", + "pinned", + "abuse_flaggers", + "resp_skip", + "resp_limit", + "resp_total", + "thread_type", + "endorsed_responses", + "non_endorsed_responses", + "non_endorsed_resp_total", + "context", + "last_activity_at", + "closed_by", + "close_reason_code", + "edit_history", + "is_spam", + "ai_moderation_reason", + "abuse_flagged", + "is_deleted", + "deleted_at", + "deleted_by", ] # updateable_fields are sent in PUT requests updatable_fields = [ - 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'read', - 'closed', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned', 'thread_type', - 'close_reason_code', 'edit_reason_code', 'closing_user_id', 'editing_user_id', + "title", + "body", + "anonymous", + "anonymous_to_peers", + "course_id", + "read", + "closed", + "user_id", + "commentable_id", + "group_id", + "group_name", + "pinned", + "thread_type", + "close_reason_code", + "edit_reason_code", + "closing_user_id", + "editing_user_id", ] # initializable_fields are sent in POST requests - initializable_fields = updatable_fields + ['thread_type', 'context'] + initializable_fields = updatable_fields + ["thread_type", "context"] base_url = f"{settings.PREFIX}/threads" - default_retrieve_params = {'recursive': False} - type = 'thread' + default_retrieve_params = {"recursive": False} + type = "thread" @classmethod def search(cls, query_params): @@ -58,82 +110,83 @@ def search(cls, query_params): # with_responses=False internally in the comment service, so no additional # optimization is required. params = { - 'page': 1, - 'per_page': 20, - 'course_id': query_params['course_id'], + "page": 1, + "per_page": 20, + "course_id": query_params["course_id"], } - params.update( - utils.strip_blank(utils.strip_none(query_params)) - ) + params.update(utils.strip_blank(utils.strip_none(query_params))) # Convert user_id and author_id to strings if present - for field in ['user_id', 'author_id']: + for field in ["user_id", "author_id"]: if value := params.get(field): params[field] = str(value) # Handle commentable_ids/commentable_id conversion - if commentable_ids := params.get('commentable_ids'): - params['commentable_ids'] = commentable_ids.split(',') - elif commentable_id := params.get('commentable_id'): - params['commentable_ids'] = [commentable_id] - params.pop('commentable_id', None) - + if commentable_ids := params.get("commentable_ids"): + params["commentable_ids"] = commentable_ids.split(",") + elif commentable_id := params.get("commentable_id"): + params["commentable_ids"] = [commentable_id] + params.pop("commentable_id", None) + if query_params.get("show_deleted", False): + params["is_deleted"] = True params = utils.clean_forum_params(params) - if query_params.get('text'): # Handle group_ids/group_id conversion - if group_ids := params.get('group_ids'): - params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] - elif group_id := params.get('group_id'): - params['group_ids'] = [int(group_id)] - params.pop('group_id', None) + if query_params.get("text"): # Handle group_ids/group_id conversion + if group_ids := params.get("group_ids"): + params["group_ids"] = [ + int(group_id) for group_id in group_ids.split(",") + ] + elif group_id := params.get("group_id"): + params["group_ids"] = [int(group_id)] + params.pop("group_id", None) response = forum_api.search_threads(**params) else: response = forum_api.get_user_threads(**params) - if query_params.get('text'): - search_query = query_params['text'] - course_id = query_params['course_id'] - group_id = query_params['group_id'] if 'group_id' in query_params else None - requested_page = params['page'] - total_results = response.get('total_results') - corrected_text = response.get('corrected_text') + if query_params.get("text"): + search_query = query_params["text"] + course_id = query_params["course_id"] + group_id = query_params["group_id"] if "group_id" in query_params else None + requested_page = params["page"] + total_results = response.get("total_results") + corrected_text = response.get("corrected_text") # Record search result metric to allow search quality analysis. # course_id is already included in the context for the event tracker tracker.emit( - 'edx.forum.searched', + "edx.forum.searched", { - 'query': search_query, - 'search_type': 'Content', - 'corrected_text': corrected_text, - 'group_id': group_id, - 'page': requested_page, - 'total_results': total_results, - } + "query": search_query, + "search_type": "Content", + "corrected_text": corrected_text, + "group_id": group_id, + "page": requested_page, + "total_results": total_results, + }, ) log.info( 'forum_text_search query="{search_query}" corrected_text="{corrected_text}" course_id={course_id} ' - 'group_id={group_id} page={requested_page} total_results={total_results}'.format( + "group_id={group_id} page={requested_page} total_results={total_results}".format( search_query=search_query, corrected_text=corrected_text, course_id=course_id, group_id=group_id, requested_page=requested_page, - total_results=total_results + total_results=total_results, ) ) return utils.CommentClientPaginatedResult( - collection=response.get('collection', []), - page=response.get('page', 1), - num_pages=response.get('num_pages', 1), - thread_count=response.get('thread_count', 0), - corrected_text=response.get('corrected_text', None) + collection=response.get("collection", []), + page=response.get("page", 1), + num_pages=response.get("num_pages", 1), + thread_count=response.get("thread_count", 0), + corrected_text=response.get("corrected_text", None), ) @classmethod def url_for_threads(cls, params=None): - if params and params.get('commentable_id'): + if params and params.get("commentable_id"): return "{prefix}/{commentable_id}/threads".format( prefix=settings.PREFIX, - commentable_id=params['commentable_id'], + commentable_id=params["commentable_id"], ) else: return f"{settings.PREFIX}/threads" @@ -146,9 +199,9 @@ def url_for_search_threads(cls): def url(cls, action, params=None): if params is None: params = {} - if action in ['get_all', 'post']: + if action in ["get_all", "post"]: return cls.url_for_threads(params) - elif action == 'search': + elif action == "search": return cls.url_for_search_threads() else: return super().url(action, params) @@ -158,21 +211,23 @@ def url(cls, action, params=None): # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): request_params = { - 'recursive': kwargs.get('recursive'), - 'with_responses': kwargs.get('with_responses', False), - 'user_id': kwargs.get('user_id'), - 'mark_as_read': kwargs.get('mark_as_read', True), - 'resp_skip': kwargs.get('response_skip'), - 'resp_limit': kwargs.get('response_limit'), - 'reverse_order': kwargs.get('reverse_order', False), - 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) + "recursive": kwargs.get("recursive"), + "with_responses": kwargs.get("with_responses", False), + "user_id": kwargs.get("user_id"), + "mark_as_read": kwargs.get("mark_as_read", True), + "resp_skip": kwargs.get("response_skip"), + "resp_limit": kwargs.get("response_limit"), + "reverse_order": kwargs.get("reverse_order", False), + "merge_question_type_responses": kwargs.get( + "merge_question_type_responses", False + ), } request_params = utils.clean_forum_params(request_params) course_id = kwargs.get("course_id") if not course_id: _, course_id = is_forum_v2_enabled_for_thread(self.id) - if user_id := request_params.get('user_id'): - request_params['user_id'] = str(user_id) + if user_id := request_params.get("user_id"): + request_params["user_id"] = str(user_id) response = forum_api.get_thread( thread_id=self.id, params=request_params, @@ -181,7 +236,7 @@ def _retrieve(self, *args, **kwargs): self._update_from_response(response) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type != 'thread': + if voteable.type != "thread": raise utils.CommentClientRequestError("Can only flag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -189,12 +244,12 @@ def flagAbuse(self, user, voteable, course_id=None): thread_id=voteable.id, action="flag", user_id=str(user.id), - course_id=str(course_key) + course_id=str(course_key), ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type != 'thread': + if voteable.type != "thread": raise utils.CommentClientRequestError("Can only unflag threads") course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) @@ -203,7 +258,7 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key) + course_id=str(course_key), ) voteable._update_from_response(response) @@ -211,18 +266,14 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): def pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.pin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) + user_id=user.id, thread_id=thread_id, course_id=str(course_key) ) self._update_from_response(response) def un_pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) response = forum_api.unpin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) + user_id=user.id, thread_id=thread_id, course_id=str(course_key) ) self._update_from_response(response) @@ -235,12 +286,15 @@ def get_user_threads_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "_type": "CommentThread" + "is_deleted": {"$ne": True}, + "_type": "CommentThread", } - return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access + return CommentThread()._collection.count_documents( + query_params + ) # pylint: disable=protected-access @classmethod - def _delete_thread(cls, thread_id, course_id=None): + def _delete_thread(cls, thread_id, course_id=None, deleted_by=None): """ Deletes a thread """ @@ -257,34 +311,53 @@ def _delete_thread(cls, thread_id, course_id=None): ) from exc start_time = time.perf_counter() - backend.delete_comments_of_a_thread(thread_id) - log.info(f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec") + # backend.delete_comments_of_a_thread(thread_id) + count_of_response_deleted, count_of_replies_deleted = ( + backend.soft_delete_comments_of_a_thread(thread_id, deleted_by) + ) + log.info( + f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec" + ) try: start_time = time.perf_counter() serialized_data = prepare_thread_api_response(thread, backend) - log.info(f"{prefix} Prepare response {time.perf_counter() - start_time} sec") + log.info( + f"{prefix} Prepare response {time.perf_counter() - start_time} sec" + ) except ValidationError as error: log.error(f"Validation error in get_thread: {error}") - raise ForumV2RequestError("Failed to prepare thread API response") from error + raise ForumV2RequestError( + "Failed to prepare thread API response" + ) from error start_time = time.perf_counter() backend.delete_subscriptions_of_a_thread(thread_id) - log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec") + log.info( + f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec" + ) start_time = time.perf_counter() - result = backend.delete_thread(thread_id) + # result = backend.delete_thread(thread_id) + result = backend.soft_delete_thread(thread_id, deleted_by) log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec") if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): start_time = time.perf_counter() backend.update_stats_for_course( - thread["author_id"], thread["course_id"], threads=-1 + thread["author_id"], + thread["course_id"], + threads=-1, + responses=-count_of_response_deleted, + replies=-count_of_replies_deleted, + deleted_threads=1, + deleted_responses=count_of_response_deleted, + deleted_replies=count_of_replies_deleted, ) log.info(f"{prefix} Update stats {time.perf_counter() - start_time} sec") return serialized_data @classmethod - def delete_user_threads(cls, user_id, course_ids): + def delete_user_threads(cls, user_id, course_ids, deleted_by=None): """ Deletes threads of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -293,21 +366,75 @@ def delete_user_threads(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), + "is_deleted": {"$ne": True}, } threads_deleted = 0 threads = CommentThread().get_list(**query_params) - log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds") + log.info( + f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds" + ) for thread in threads: start_time = time.time() thread_id = thread.get("_id") course_id = thread.get("course_id") if thread_id: - cls._delete_thread(thread_id, course_id=course_id) + cls._delete_thread( + thread_id, course_id=course_id, deleted_by=deleted_by + ) threads_deleted += 1 - log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." - f" Thread Found: {thread_id is not None}") + log.info( + f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." + f" Thread Found: {thread_id is not None}" + ) return threads_deleted + @classmethod + def get_user_deleted_threads_count(cls, user_id, course_ids): + """ + Returns count of deleted threads for user in the given course_ids. + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "CommentThread", + "is_deleted": True, + } + return CommentThread()._collection.count_documents( + query_params + ) # pylint: disable=protected-access + + @classmethod + def restore_user_deleted_threads(cls, user_id, course_ids, restored_by=None): + """ + Restores (undeletes) threads of user in the given course_ids by setting is_deleted=False. + """ + return forum_api.restore_user_deleted_threads( + user_id=str(user_id), + course_ids=course_ids, + course_id=course_ids[0] if course_ids else None, + restored_by=restored_by, + ) + + @classmethod + def restore_thread(cls, thread_id, course_id=None, restored_by=None): + """ + Restores an individual soft-deleted thread by setting is_deleted=False + Public method for individual thread restoration + """ + return forum_api.restore_thread( + thread_id=thread_id, course_id=course_id, restored_by=restored_by + ) + + @classmethod + def _restore_thread(cls, thread_id, course_id=None, restored_by=None): + """ + Restores a soft-deleted thread by setting is_deleted=False and clearing deletion metadata + Internal method that delegates to forum API + """ + return forum_api.restore_thread( + thread_id=thread_id, course_id=course_id, restored_by=restored_by + ) + def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index 26625ed3a732..62ce9b137642 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -63,6 +63,7 @@ def perform_request(method, url, data_or_params=None, raw=False, data = None params = data_or_params.copy() params.update(request_id_dict) + import pdb;pdb.set_trace() response = requests.request( method, url,