diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index fcc13efc40b8..b87852c16cfa 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,26 +19,24 @@ 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 forum import api as forum_api +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, +) + 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, @@ -50,12 +48,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, @@ -63,13 +61,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_edited, comment_endorsed, + comment_edited, comment_flagged, comment_voted, thread_created, @@ -77,15 +75,11 @@ 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 @@ -94,27 +88,21 @@ 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, -) -from .exceptions import ( - CommentNotFoundError, - DiscussionBlackOutException, - DiscussionDisabledError, - ThreadNotFoundError, + is_commentable_divided ) +from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering from .pagination import DiscussionAPIPagination from .permissions import ( @@ -122,7 +110,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, @@ -131,23 +119,20 @@ 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, - is_captcha_enabled, - is_posting_allowed, set_attribute, + is_posting_allowed, + can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform ) -log = logging.getLogger(__name__) User = get_user_model() ThreadType = Literal["discussion", "question"] @@ -181,14 +166,11 @@ 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 @@ -206,16 +188,14 @@ def _get_course( 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.") @@ -236,34 +216,22 @@ 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: @@ -296,8 +264,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"] ) @@ -307,13 +275,11 @@ 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 []) - ) - return request.build_absolute_uri( - urlunparse(("", "", path, "", urlencode(query_list), "")) + [("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), ""))) def get_course(request, course_key, check_tab=True): @@ -358,19 +324,18 @@ 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 { @@ -384,9 +349,7 @@ 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}) ), @@ -394,23 +357,18 @@ 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() @@ -419,23 +377,17 @@ 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), } @@ -488,7 +440,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) @@ -530,7 +482,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. @@ -554,17 +506,13 @@ 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"]), - ) - non_courseware_topics.append( - DiscussionTopicSerializer(discussion_topic).data + thread_counts.get(entry["id"]) ) + non_courseware_topics.append(DiscussionTopicSerializer(discussion_topic).data) if topic_ids and entry["id"] in topic_ids: existing_topic_ids.add(entry["id"]) @@ -572,9 +520,7 @@ 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. @@ -598,25 +544,15 @@ def get_course_topics( 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 { @@ -631,19 +567,17 @@ 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 @@ -655,25 +589,23 @@ 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, @@ -686,7 +618,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 @@ -703,21 +635,20 @@ 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( @@ -774,29 +705,24 @@ 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) @@ -806,13 +732,9 @@ 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) @@ -824,13 +746,11 @@ 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 @@ -857,7 +777,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): @@ -865,7 +785,11 @@ 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): @@ -883,28 +807,22 @@ 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. @@ -922,13 +840,9 @@ 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 @@ -937,12 +851,10 @@ 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. @@ -973,19 +885,14 @@ def _serialize_discussion_entities( 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 ( - serialized_entity["author"] - and serialized_entity["author"] not in usernames + '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["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"]) + usernames.append(serialized_entity['endorsed_by']) results = _add_additional_response_fields( request, results, usernames, discussion_entity_type, include_profile_image @@ -1009,7 +916,6 @@ 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 @@ -1053,31 +959,20 @@ 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) @@ -1089,21 +984,13 @@ 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." - ) - if show_deleted and not context["has_moderation_privilege"]: - raise PermissionDenied( - "`show_deleted` 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.") group_id = None allowed_roles = [ @@ -1123,9 +1010,7 @@ 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), @@ -1138,24 +1023,21 @@ 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 @@ -1165,25 +1047,19 @@ 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, - ) - return paginator.get_paginated_response( - { - "results": results, - "text_search_rewrite": paginated_results.corrected_text, - } + paginated_results.thread_count ) + 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): @@ -1278,101 +1154,49 @@ 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) - show_deleted = query_params.get("show_deleted", False) - if isinstance(show_deleted, str): - show_deleted = show_deleted.lower() == "true" - + group_id = query_params.get('group_id', None) + user_id = query_params.get('user_id', None) + count_flagged = query_params.get('count_flagged', None) 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, - filtered_threads, - {"profile_image"}, - DiscussionEntity.thread, + request, context, threads, {'profile_image'}, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( - request, page, num_pages, len(filtered_threads) - ) - return paginator.get_paginated_response( - { - "results": results, - } + request, + page, + num_pages, + len(threads) ) + 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, - show_deleted=False, -): +def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None, + merge_question_type_responses=False): """ Return the list of comments in the given thread. @@ -1402,7 +1226,7 @@ def get_comment_list( 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, @@ -1415,23 +1239,19 @@ def get_comment_list( "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"] @@ -1440,11 +1260,7 @@ def get_comment_list( 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"] @@ -1456,21 +1272,9 @@ def get_comment_list( raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - 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 - ) + results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) - paginator = DiscussionAPIPagination(request, page, num_pages, len(responses)) + paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) @@ -1488,9 +1292,7 @@ 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) @@ -1512,7 +1314,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." ) @@ -1533,7 +1335,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." ) @@ -1543,40 +1345,28 @@ 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: @@ -1586,7 +1376,7 @@ def _do_extra_actions( 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: @@ -1599,19 +1389,15 @@ 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) @@ -1620,7 +1406,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") @@ -1629,11 +1415,7 @@ 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 ) @@ -1641,7 +1423,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. @@ -1708,35 +1490,24 @@ 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 @@ -1775,30 +1546,15 @@ 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 @@ -1820,32 +1576,24 @@ 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 @@ -1880,27 +1628,16 @@ 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 @@ -1934,9 +1671,7 @@ 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): @@ -1964,10 +1699,7 @@ 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) - show_deleted = request.GET.get("show_deleted", False) - show_deleted = show_deleted in ["true", "True", True] - + reverse_order = request.GET.get('reverse_order', False) cc_thread, context = _get_thread_and_context( request, cc_comment["thread_id"], @@ -1975,13 +1707,10 @@ 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 = [] @@ -1991,35 +1720,16 @@ 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(paged_response_comments) - num_pages = ( - (comments_count + page_size - 1) // page_size if comments_count else 1 - ) + comments_count = len(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: @@ -2063,20 +1773,16 @@ 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 @@ -2116,7 +1822,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(deleted_by=str(request.user.id)) + cc_thread.delete() thread_deleted.send(sender=None, user=request.user, post=cc_thread) track_thread_deleted_event(request, context["course"], cc_thread) else: @@ -2141,7 +1847,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(deleted_by=str(request.user.id)) + cc_comment.delete() comment_deleted.send(sender=None, user=request.user, post=cc_comment) track_comment_deleted_event(request, context["course"], cc_comment) else: @@ -2173,10 +1879,7 @@ 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: @@ -2185,35 +1888,30 @@ 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) @@ -2233,429 +1931,71 @@ 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"] - ), - ) - return paginator.get_paginated_response( - { - "results": serializer.data, - } + 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, + }) 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.""" - 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): - # If user_id has any issues, there's no label to return - 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): - # If user not found or invalid ID, skip setting deleted fields - 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): - # If user not found or invalid ID, skip setting deleted fields - 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 f37543723792..8cc7127645b2 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -1,7 +1,6 @@ """ Discussion API forms """ - import urllib.parse from django.core.exceptions import ValidationError @@ -23,15 +22,13 @@ class UserOrdering(TextChoices): - BY_ACTIVITY = "activity" - BY_FLAGS = "flagged" - BY_RECENT_ACTIVITY = "recency" - BY_DELETED = "deleted" + BY_ACTIVITY = 'activity' + BY_FLAGS = 'flagged' + BY_RECENT_ACTIVITY = 'recency' class _PaginationForm(Form): """A form that includes pagination fields""" - page = IntegerField(required=False, min_value=1) page_size = IntegerField(required=False, min_value=1) @@ -48,7 +45,6 @@ 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() @@ -62,22 +58,17 @@ 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) @@ -94,16 +85,14 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError as e: - raise ValidationError(f"'{value}' is not a valid course id") from e + except InvalidKeyError: + raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from 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 @@ -126,7 +115,6 @@ 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) @@ -138,20 +126,17 @@ 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) @@ -161,8 +146,8 @@ def clean_course_id(self): value = self.cleaned_data["course_id"] try: return CourseLocator.from_string(value) - except InvalidKeyError as e: - raise ValidationError(f"'{value}' is not a valid course id") from e + except InvalidKeyError: + raise ValidationError(f"'{value}' is not a valid course id") # lint-amnesty, pylint: disable=raise-missing-from class CommentActionsForm(Form): @@ -170,7 +155,6 @@ 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) @@ -179,7 +163,6 @@ class CommentGetForm(_PaginationForm): """ A form to validate query parameters in the comment retrieval endpoint """ - requested_fields = MultiValueField(required=False) @@ -187,34 +170,28 @@ 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 as e: - raise ValidationError( - f"'{str(course_id)}' is not a valid course key" - ) from e + except InvalidKeyError: + raise ValidationError(f"'{str(course_id)}' is not a valid course key") # lint-amnesty, pylint: disable=raise-missing-from 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), @@ -222,20 +199,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 @@ -243,17 +220,15 @@ 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 902a433dac3b..8a7ab16e0903 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,12 +18,8 @@ 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_comment_edited_event, - track_forum_response_mark_event, - track_thread_edited_event, - track_thread_lock_unlock_event, -) +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.utils import ( course_discussion_division_enabled, get_group_id_for_user, @@ -39,23 +35,17 @@ from lms.djangoapps.discussion.rest_api.render import render_body from lms.djangoapps.discussion.rest_api.utils import ( get_course_staff_users_list, - get_course_ta_users_list, get_moderator_users_list, + get_course_ta_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 @@ -69,7 +59,6 @@ class TopicOrdering(TextChoices): """ Enum for the available options for ordering topics. """ - COURSE_STRUCTURE = "course_structure", "Course Structure" ACTIVITY = "activity", "Activity" NAME = "name", "Name" @@ -84,24 +73,16 @@ 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, @@ -156,8 +137,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 @@ -177,7 +158,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)) @@ -186,7 +167,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 @@ -215,14 +196,8 @@ 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() @@ -244,10 +219,7 @@ 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): """ @@ -255,12 +227,12 @@ 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"] - ) + 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 + return ( + obj["anonymous"] or + obj["anonymous_to_peers"] and not is_user_staff + ) def get_author(self, obj): """ @@ -278,9 +250,10 @@ 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): @@ -330,9 +303,7 @@ 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 @@ -344,9 +315,8 @@ 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): @@ -379,7 +349,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") @@ -395,57 +365,12 @@ 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")) - - 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 + return self._get_user_label_from_username(last_edit.get('editor_username')) class ThreadSerializer(_ContentSerializer): @@ -456,15 +381,13 @@ 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) @@ -479,12 +402,8 @@ 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) @@ -530,8 +449,9 @@ 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") @@ -575,17 +495,13 @@ 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") @@ -596,7 +512,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") @@ -604,7 +520,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")) @@ -619,31 +535,18 @@ 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 @@ -656,7 +559,6 @@ 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) @@ -671,7 +573,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: @@ -693,8 +595,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 @@ -736,7 +638,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 @@ -778,7 +680,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 @@ -791,18 +693,12 @@ 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 @@ -812,7 +708,6 @@ 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) @@ -842,11 +737,10 @@ 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, @@ -870,13 +764,10 @@ 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): @@ -884,7 +775,10 @@ 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() @@ -905,16 +799,14 @@ 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): @@ -932,7 +824,6 @@ class DiscussionRolesMemberSerializer(serializers.Serializer): """ Serializer for course discussion roles member data. """ - username = serializers.CharField() email = serializers.EmailField() first_name = serializers.CharField() @@ -941,7 +832,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.""" @@ -964,7 +855,6 @@ class DiscussionRolesListSerializer(serializers.Serializer): """ Serializer for course discussion roles member list. """ - course_id = serializers.CharField() results = serializers.SerializerMethodField() division_scheme = serializers.SerializerMethodField() @@ -972,17 +862,15 @@ 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): """ @@ -999,13 +887,9 @@ 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() @@ -1023,36 +907,27 @@ 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.", @@ -1060,9 +935,7 @@ 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 5773fbbc83b0..cd725a3513dc 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,36 +1,32 @@ """ 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 eventtracking import tracker from opaque_keys.edx.locator import CourseKey +from eventtracking import tracker -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole 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 """ @@ -44,21 +40,17 @@ def send_thread_created_notification( 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. """ @@ -67,10 +59,8 @@ def send_response_notifications( 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() @@ -79,9 +69,7 @@ def send_response_notifications( @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 """ @@ -92,10 +80,8 @@ def send_response_endorsed_notifications( 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 @@ -113,63 +99,15 @@ 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}" - ) - # 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" + 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' 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/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 2fa761b46615..53c12454aec9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -10,20 +10,34 @@ import random from datetime import datetime, timedelta from unittest import mock +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import ddt import httpretty import pytest +from django.test import override_settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test.client import RequestFactory +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.partitions.partitions import Group, UserPartition + from common.djangoapps.student.tests.factories import ( AdminFactory, + BetaTesterFactory, CourseEnrollmentFactory, + StaffFactory, UserFactory, ) from common.djangoapps.util.testing import UrlResetMixin @@ -31,6 +45,10 @@ from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, ) +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.api import ( create_comment, @@ -38,9 +56,12 @@ delete_comment, delete_thread, get_comment_list, + get_course, + get_course_topics, get_course_topics_v2, get_thread, get_thread_list, + get_user_comments, update_comment, update_thread, ) @@ -52,19 +73,18 @@ ) from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, ForumMockUtilsMixin, make_paginated_api_response, + parsed_body, ) -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) +from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, DiscussionTopicLink, - PostingRestriction, Provider, + PostingRestriction, ) from openedx.core.djangoapps.discussions.tasks import ( update_discussions_settings_from_course_task, @@ -78,13 +98,6 @@ Role, ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory User = get_user_model() @@ -261,11 +274,7 @@ def test_basic(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -344,11 +353,7 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): ) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data( @@ -374,7 +379,6 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "type", "voted", ], - "is_deleted": False, } ) assert actual == expected @@ -426,11 +430,7 @@ def test_title_truncation(self, mock_emit): ) self.register_post_thread_response(cs_thread) with self.assert_signal_sent( - api, - "thread_created", - sender=None, - user=self.user, - exclude_args=("post", "notify_all_learners"), + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") ): create_thread(self.request, data) event_name, event_data = mock_emit.call_args[0] @@ -718,10 +718,6 @@ def test_success(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected @@ -830,10 +826,6 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": False, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected @@ -922,9 +914,7 @@ def test_endorsed(self, role_name, is_thread_author, thread_type): ) try: create_comment(self.request, data) - last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][ - 1 - ] + last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][1] assert last_commemt_params["endorsed"] assert not expected_error except ValidationError: @@ -1838,10 +1828,6 @@ def test_basic(self, parent_id): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert actual == expected params = { @@ -1902,7 +1888,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): else "edx.forum.response.unreported" ) expected_event_data = { - "discussion": {"id": "test_thread"}, + "discussion": {'id': 'test_thread'}, "body": "Original body", "id": "test_comment", "content_type": "Response", @@ -1965,7 +1951,7 @@ def test_comment_un_abuse_flag_for_moderator_role( "body": "Original body", "id": "test_comment", "content_type": "Response", - "discussion": {"id": "test_thread"}, + "discussion": {'id': 'test_thread'}, "commentable_id": "dummy", "truncated": False, "url": "", @@ -2384,7 +2370,6 @@ def test_basic(self, mock_emit): params = { "thread_id": self.thread_id, "course_id": str(self.course.id), - "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_thread", -1, **params) @@ -2572,7 +2557,6 @@ def test_basic(self, mock_emit): params = { "comment_id": self.comment_id, "course_id": str(self.course.id), - "deleted_by": str(self.user.id), } self.check_mock_called_with("delete_comment", -1, **params) @@ -2937,7 +2921,6 @@ def test_get_threads_by_topic_id(self): "page": 1, "per_page": 1, "commentable_ids": ["topic_x", "topic_meow"], - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -2953,7 +2936,6 @@ def test_basic_query_params(self): "sort_key": "activity", "page": 6, "per_page": 14, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3094,10 +3076,10 @@ def test_request_group(self, role_name, course_is_cohorted): self.get_thread_list([], course=cohort_course) thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] actual_has_group = "group_id" in thread_func_params - expected_has_group = course_is_cohorted and role_name in ( - FORUM_ROLE_STUDENT, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_GROUP_MODERATOR, + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) ) assert actual_has_group == expected_has_group @@ -3162,7 +3144,6 @@ def test_text_search(self, text_search_rewrite): "page": 1, "per_page": 10, "text": "test search string", - "show_deleted": False, } self.check_mock_called_with( "search_threads", @@ -3189,7 +3170,6 @@ def test_filter_threads_by_author(self): "page": 1, "per_page": 10, "author_id": str(self.user.id), - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3236,7 +3216,6 @@ def test_thread_type(self, thread_type): "page": 1, "per_page": 10, "thread_type": thread_type, - "show_deleted": False, } if thread_type is None: @@ -3274,7 +3253,6 @@ def test_flagged(self, flagged_boolean): "page": 1, "per_page": 10, "flagged": flagged_boolean, - "show_deleted": False, } if flagged_boolean is None: @@ -3315,7 +3293,6 @@ def test_flagged_count(self, role): "count_flagged": True, "page": 1, "per_page": 10, - "show_deleted": False, } self.check_mock_called_with( @@ -3364,7 +3341,6 @@ def test_following(self): "sort_key": "activity", "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with("get_user_subscriptions", -1, **params) @@ -3392,7 +3368,6 @@ def test_view_query(self, query): "page": 1, "per_page": 11, query: True, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3434,7 +3409,6 @@ def test_order_by_query(self, http_query, cc_query): "sort_key": cc_query, "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3467,7 +3441,6 @@ def test_order_direction(self): "sort_key": "activity", "page": 1, "per_page": 11, - "show_deleted": False, } self.check_mock_called_with( "get_user_threads", @@ -3796,10 +3769,6 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, }, { "id": "test_comment_2", @@ -3835,10 +3804,6 @@ def get_source_and_expected_comments(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, }, ] return source_comments, expected_comments diff --git a/lms/djangoapps/discussion/rest_api/tests/test_forms.py b/lms/djangoapps/discussion/rest_api/tests/test_forms.py index 33359337933b..3be65964b6b9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_forms.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_forms.py @@ -2,6 +2,7 @@ Tests for Discussion API forms """ + import itertools from unittest import TestCase from urllib.parse import urlencode @@ -11,9 +12,9 @@ from opaque_keys.edx.locator import CourseLocator from lms.djangoapps.discussion.rest_api.forms import ( + UserCommentListGetForm, CommentListGetForm, ThreadListGetForm, - UserCommentListGetForm, ) from openedx.core.djangoapps.util.test_forms import FormTestMixin @@ -35,9 +36,7 @@ def test_missing_page_size(self): def test_zero_page_size(self): self.form_data["page_size"] = "0" - self.assert_error( - "page_size", "Ensure this value is greater than or equal to 1." - ) + self.assert_error("page_size", "Ensure this value is greater than or equal to 1.") def test_excessive_page_size(self): self.form_data["page_size"] = "101" @@ -47,7 +46,6 @@ def test_excessive_page_size(self): @ddt.ddt class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for ThreadListGetForm""" - FORM_CLASS = ThreadListGetForm def setUp(self): @@ -60,41 +58,37 @@ def setUp(self): "page_size": "13", } ), - mutable=True, + mutable=True ) def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "course_id": CourseLocator.from_string("Foo/Bar/Baz"), - "page": 2, - "page_size": 13, - "count_flagged": None, - "topic_id": set(), - "text_search": "", - "following": None, - "author": "", - "thread_type": "", - "flagged": None, - "show_deleted": None, - "view": "", - "order_by": "last_activity_at", - "order_direction": "desc", - "requested_fields": set(), + 'course_id': CourseLocator.from_string('Foo/Bar/Baz'), + 'page': 2, + 'page_size': 13, + 'count_flagged': None, + 'topic_id': set(), + 'text_search': '', + 'following': None, + 'author': '', + 'thread_type': '', + 'flagged': None, + 'view': '', + 'order_by': 'last_activity_at', + 'order_direction': 'desc', + 'requested_fields': set() } def test_topic_id(self): self.form_data.setlist("topic_id", ["example topic_id", "example 2nd topic_id"]) form = self.get_form(expected_valid=True) - assert form.cleaned_data["topic_id"] == { - "example topic_id", - "example 2nd topic_id", - } + assert form.cleaned_data['topic_id'] == {'example topic_id', 'example 2nd topic_id'} def test_text_search(self): self.form_data["text_search"] = "test search string" form = self.get_form(expected_valid=True) - assert form.cleaned_data["text_search"] == "test search string" + assert form.cleaned_data['text_search'] == 'test search string' def test_missing_course_id(self): self.form_data.pop("course_id") @@ -115,10 +109,7 @@ def test_thread_type(self, value): def test_thread_type_invalid(self): self.form_data["thread_type"] = "invalid-option" - self.assert_error( - "thread_type", - "Select a valid choice. invalid-option is not one of the available choices.", - ) + self.assert_error("thread_type", "Select a valid choice. invalid-option is not one of the available choices.") @ddt.data("True", "true", 1, True) def test_flagged_true(self, value): @@ -142,9 +133,7 @@ def test_following_true(self, value): @ddt.data("False", "false", 0, False) def test_following_false(self, value): self.form_data["following"] = value - self.assert_error( - "following", "The value of the 'following' parameter must be true." - ) + self.assert_error("following", "The value of the 'following' parameter must be true.") def test_invalid_following(self): self.form_data["following"] = "invalid-boolean" @@ -155,28 +144,25 @@ def test_mutually_exclusive(self, params): self.form_data.update({param: "True" for param in params}) self.assert_error( "__all__", - "The following query parameters are mutually exclusive: topic_id, text_search, following", + "The following query parameters are mutually exclusive: topic_id, text_search, following" ) def test_invalid_view_choice(self): self.form_data["view"] = "not_a_valid_choice" - self.assert_error( - "view", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", - ) + self.assert_error("view", "Select a valid choice. not_a_valid_choice is not one of the available choices.") def test_invalid_sort_by_choice(self): self.form_data["order_by"] = "not_a_valid_choice" self.assert_error( "order_by", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", + "Select a valid choice. not_a_valid_choice is not one of the available choices." ) def test_invalid_sort_direction_choice(self): self.form_data["order_direction"] = "not_a_valid_choice" self.assert_error( "order_direction", - "Select a valid choice. not_a_valid_choice is not one of the available choices.", + "Select a valid choice. not_a_valid_choice is not one of the available choices." ) @ddt.data( @@ -195,13 +181,12 @@ def test_valid_choice_fields(self, field, value): def test_requested_fields(self): self.form_data["requested_fields"] = "profile_image" form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} @ddt.ddt class CommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for CommentListGetForm""" - FORM_CLASS = CommentListGetForm def setUp(self): @@ -217,14 +202,13 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "thread_id": "deadbeef", - "endorsed": False, - "page": 2, - "page_size": 13, - "flagged": False, - "requested_fields": set(), - "merge_question_type_responses": False, - "show_deleted": None, + 'thread_id': 'deadbeef', + 'endorsed': False, + 'page': 2, + 'page_size': 13, + 'flagged': False, + 'requested_fields': set(), + 'merge_question_type_responses': False } def test_missing_thread_id(self): @@ -252,13 +236,12 @@ def test_invalid_endorsed(self): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} @ddt.ddt class UserCommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for UserCommentListGetForm""" - FORM_CLASS = UserCommentListGetForm def setUp(self): @@ -273,11 +256,11 @@ def setUp(self): def test_basic(self): form = self.get_form(expected_valid=True) assert form.cleaned_data == { - "course_id": CourseLocator.from_string("a/b/c"), - "flagged": False, - "page": 2, - "page_size": 13, - "requested_fields": set(), + 'course_id': CourseLocator.from_string('a/b/c'), + 'flagged': False, + 'page': 2, + 'page_size': 13, + 'requested_fields': set() } def test_missing_flagged(self): @@ -297,7 +280,7 @@ def test_flagged_true(self, value): def test_requested_fields(self): self.form_data["requested_fields"] = {"profile_image"} form = self.get_form(expected_valid=True) - assert form.cleaned_data["requested_fields"] == {"profile_image"} + assert form.cleaned_data['requested_fields'] == {'profile_image'} def test_missing_course_id(self): self.form_data.pop("course_id") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 10f9b7a64248..a1443252a1ce 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -9,17 +9,19 @@ import httpretty from django.test.client import RequestFactory from django.test.utils import override_settings +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, -) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api.serializers import ( CommentSerializer, ThreadSerializer, filter_spam_urls_from_html, - get_context, + get_context ) from lms.djangoapps.discussion.rest_api.tests.utils import ( CommentsServiceMockMixin, @@ -37,10 +39,6 @@ FORUM_ROLE_STUDENT, Role, ) -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt @@ -48,18 +46,13 @@ class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetM """ Test Mixin for Serializer tests """ - @classmethod - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() httpretty.reset() @@ -67,8 +60,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -96,9 +89,7 @@ def create_role(self, role_name, users, course=None): (FORUM_ROLE_STUDENT, False, True, True), ) @ddt.unpack - def test_anonymity( - self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous - ): + def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous): """ Test that content is properly made anonymous. @@ -116,9 +107,7 @@ def test_anonymity( """ self.create_role(role_name, [self.user]) serialized = self.serialize( - self.make_cs_content( - {"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers} - ) + self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers}) ) actual_serialized_anonymous = serialized["author"] is None assert actual_serialized_anonymous == expected_serialized_anonymous @@ -149,19 +138,17 @@ def test_author_labels(self, role_name, anonymous, expected_label): """ self.create_role(role_name, [self.author]) serialized = self.serialize(self.make_cs_content({"anonymous": anonymous})) - assert serialized["author_label"] == expected_label + assert serialized['author_label'] == expected_label def test_abuse_flagged(self): - serialized = self.serialize( - self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}) - ) - assert serialized["abuse_flagged"] is True + serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]})) + assert serialized['abuse_flagged'] is True def test_voted(self): thread_id = "test_thread" self.register_get_user_response(self.user, upvoted_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized["voted"] is True + assert serialized['voted'] is True @ddt.ddt @@ -188,61 +175,47 @@ def serialize(self, thread): Create a serializer with an appropriate context and use it to serialize the given thread, returning the result. """ - return ThreadSerializer( - thread, context=get_context(self.course, self.request) - ).data + return ThreadSerializer(thread, context=get_context(self.course, self.request)).data def test_basic(self): - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.author.id), - "username": self.author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - expected = self.expected_thread_data( - { - "author": self.author.username, - "can_delete": False, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": [ - "abuse_flagged", - "copy_link", - "following", - "read", - "voted", - ], - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": None, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.author.id), + "username": self.author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + }) + expected = self.expected_thread_data({ + "author": self.author.username, + "can_delete": False, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, + }) assert self.serialize(thread) == expected thread["thread_type"] = "question" - expected.update( - { - "type": "question", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" - ), - } - ) + expected.update({ + "type": "question", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" + ), + }) assert self.serialize(thread) == expected def test_pinned_missing(self): @@ -254,34 +227,34 @@ def test_pinned_missing(self): del thread_data["pinned"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized["pinned"] is False + assert serialized['pinned'] is False def test_group(self): self.course.cohort_config = {"cohorted": True} modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) cohort = CohortFactory.create(course_id=self.course.id) serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) - assert serialized["group_id"] == cohort.id - assert serialized["group_name"] == cohort.name + assert serialized['group_id'] == cohort.id + assert serialized['group_name'] == cohort.name def test_following(self): thread_id = "test_thread" self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id]) serialized = self.serialize(self.make_cs_content({"id": thread_id})) - assert serialized["following"] is True + assert serialized['following'] is True def test_response_count(self): thread_data = self.make_cs_content({"resp_total": 2}) self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert serialized["response_count"] == 2 + assert serialized['response_count'] == 2 def test_response_count_missing(self): thread_data = self.make_cs_content({}) del thread_data["resp_total"] self.register_get_thread_response(thread_data) serialized = self.serialize(thread_data) - assert "response_count" not in serialized + assert 'response_count' not in serialized @ddt.data( (FORUM_ROLE_MODERATOR, True), @@ -299,62 +272,43 @@ def test_closed_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": moderator, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator + }) closed_by_label = "Moderator" if visible else None closed_by = moderator if visible else None can_delete = role != FORUM_ROLE_STUDENT editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend( - ["anonymous", "raw_body", "title", "topic_id", "type"] - ) + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend( - [ - "close_reason_code", - "closed", - "edit_reason_code", - "pinned", - "raw_body", - "title", - "topic_id", - "type", - ] - ) - # is_deleted is visible (False) for privileged users, hidden (None) for others - is_deleted = False if role == FORUM_ROLE_MODERATOR else None - expected = self.expected_thread_data( - { - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "edit_by_label": None, - "closed_by_label": closed_by_label, - "closed_by": closed_by, - "is_deleted": is_deleted, - } - ) + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + }) assert self.serialize(thread) == expected @ddt.data( @@ -373,69 +327,48 @@ def test_edit_by_label_field(self, role, visible): self.create_role(FORUM_ROLE_MODERATOR, [moderator]) self.create_role(request_role, [self.user]) - thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(author.id), - "username": author.username, - "title": "Test Title", - "body": "Test body", - "pinned": True, - "votes": {"up_count": 4}, - "edit_history": [{"editor_username": moderator}], - "comments_count": 5, - "unread_comments_count": 3, - "closed_by": None, - } - ) + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None + }) edit_by_label = "Moderator" if visible else None can_delete = role != FORUM_ROLE_STUDENT - last_edit = ( - None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} - ) + last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": editable_fields.remove("voted") - editable_fields.extend( - ["anonymous", "raw_body", "title", "topic_id", "type"] - ) + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend( - [ - "close_reason_code", - "closed", - "edit_reason_code", - "pinned", - "raw_body", - "title", - "topic_id", - "type", - ] - ) + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) - # is_deleted is visible (False) for privileged users, hidden (None) for others - is_deleted = False if role == FORUM_ROLE_MODERATOR else None - expected = self.expected_thread_data( - { - "author": author.username, - "can_delete": can_delete, - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "pinned": True, - "editable_fields": sorted(editable_fields), - "abuse_flagged_count": None, - "last_edit": last_edit, - "edit_by_label": edit_by_label, - "closed_by_label": None, - "closed_by": None, - "is_deleted": is_deleted, - } - ) + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + }) assert self.serialize(thread) == expected def test_get_preview_body(self): @@ -451,10 +384,7 @@ def test_get_preview_body(self): {"body": "

This is a test thread body with some text.

"} ) serialized = self.serialize(thread_data) - assert ( - serialized["preview_body"] - == "This is a test thread body with some text." - ) + assert serialized['preview_body'] == "This is a test thread body with some text." @ddt.ddt @@ -472,12 +402,12 @@ def make_cs_content(self, overrides=None, with_endorsement=False): """ merged_overrides = { "user_id": str(self.author.id), - "username": self.author.username, + "username": self.author.username } if with_endorsement: merged_overrides["endorsement"] = { "user_id": str(self.endorser.id), - "time": self.endorsed_at, + "time": self.endorsed_at } merged_overrides.update(overrides or {}) return make_minimal_cs_comment(merged_overrides) @@ -487,9 +417,7 @@ def serialize(self, comment, thread_data=None): Create a serializer with an appropriate context and use it to serialize the given comment, returning the result. """ - context = get_context( - self.course, self.request, make_minimal_cs_thread(thread_data) - ) + context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data)) return CommentSerializer(comment, context=context).data def test_basic(self): @@ -544,10 +472,6 @@ def test_basic(self): "image_url_medium": "http://testserver/static/default_50.png", "image_url_small": "http://testserver/static/default_30.png", }, - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } assert self.serialize(comment) == expected @@ -560,7 +484,7 @@ def test_basic(self): FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT, ], - [True, False], + [True, False] ) ) @ddt.unpack @@ -577,12 +501,10 @@ def test_endorsed_by(self, endorser_role_name, thread_anonymous): self.create_role(endorser_role_name, [self.endorser]) serialized = self.serialize( self.make_cs_content(with_endorsement=True), - thread_data={"anonymous": thread_anonymous}, + thread_data={"anonymous": thread_anonymous} ) actual_endorser_anonymous = serialized["endorsed_by"] is None - expected_endorser_anonymous = ( - endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous - ) + expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous assert actual_endorser_anonymous == expected_endorser_anonymous @ddt.data( @@ -605,69 +527,56 @@ def test_endorsed_by_labels(self, role_name, expected_label): """ self.create_role(role_name, [self.endorser]) serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized["endorsed_by_label"] == expected_label + assert serialized['endorsed_by_label'] == expected_label def test_endorsed_at(self): serialized = self.serialize(self.make_cs_content(with_endorsement=True)) - assert serialized["endorsed_at"] == self.endorsed_at + assert serialized['endorsed_at'] == self.endorsed_at def test_children(self): - comment = self.make_cs_content( - { - "id": "test_root", - "children": [ - self.make_cs_content( - { - "id": "test_child_1", - "parent_id": "test_root", - } - ), - self.make_cs_content( - { - "id": "test_child_2", - "parent_id": "test_root", - "children": [ - self.make_cs_content( - { - "id": "test_grandchild", - "parent_id": "test_child_2", - } - ) - ], - } - ), - ], - } - ) + comment = self.make_cs_content({ + "id": "test_root", + "children": [ + self.make_cs_content({ + "id": "test_child_1", + "parent_id": "test_root", + }), + self.make_cs_content({ + "id": "test_child_2", + "parent_id": "test_root", + "children": [ + self.make_cs_content({ + "id": "test_grandchild", + "parent_id": "test_child_2" + }) + ], + }), + ], + }) serialized = self.serialize(comment) - assert serialized["children"][0]["id"] == "test_child_1" - assert serialized["children"][0]["parent_id"] == "test_root" - assert serialized["children"][1]["id"] == "test_child_2" - assert serialized["children"][1]["parent_id"] == "test_root" - assert serialized["children"][1]["children"][0]["id"] == "test_grandchild" - assert serialized["children"][1]["children"][0]["parent_id"] == "test_child_2" + assert serialized['children'][0]['id'] == 'test_child_1' + assert serialized['children'][0]['parent_id'] == 'test_root' + assert serialized['children'][1]['id'] == 'test_child_2' + assert serialized['children'][1]['parent_id'] == 'test_root' + assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild' + assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2' @ddt.ddt class ThreadSerializerDeserializationTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, + ForumsEnableMixin, + CommentsServiceMockMixin, + UrlResetMixin, + SharedModuleStoreTestCase ): """Tests for ThreadSerializer deserialization.""" - @classmethod - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() httpretty.reset() @@ -675,8 +584,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -691,22 +600,18 @@ def setUp(self): "title": "Test Title", "raw_body": "Test body", } - self.existing_thread = Thread( - **make_minimal_cs_thread( - { - "id": "existing_thread", - "course_id": str(self.course.id), - "commentable_id": "original_topic", - "thread_type": "discussion", - "title": "Original Title", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "read": "False", - "endorsed": "False", - } - ) - ) + self.existing_thread = Thread(**make_minimal_cs_thread({ + "id": "existing_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "read": "False", + "endorsed": "False" + })) def save_and_reserialize(self, data, instance=None): """ @@ -718,7 +623,7 @@ def save_and_reserialize(self, data, instance=None): instance, data=data, partial=(instance is not None), - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert serializer.is_valid() serializer.save() @@ -730,36 +635,33 @@ def test_create_missing_field(self): data.pop(field) serializer = ThreadSerializer(data=data) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is required."]} + assert serializer.errors == {field: ['This field is required.']} @ddt.data("", " ") def test_create_empty_string(self, value): data = self.minimal_data.copy() data.update({field: value for field in ["topic_id", "title", "raw_body"]}) - serializer = ThreadSerializer( - data=data, context=get_context(self.course, self.request) - ) + serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request)) assert not serializer.is_valid() assert serializer.errors == { - field: ["This field may not be blank."] - for field in ["topic_id", "title", "raw_body"] + field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] } def test_update_empty(self): self.register_put_thread_response(self.existing_thread.attributes) self.save_and_reserialize({}, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - "course_id": [str(self.course.id)], - "commentable_id": ["original_topic"], - "thread_type": ["discussion"], - "title": ["Original Title"], - "body": ["Original body"], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "closed": ["False"], - "pinned": ["False"], - "user_id": [str(self.user.id)], - "read": ["False"], + 'course_id': [str(self.course.id)], + 'commentable_id': ['original_topic'], + 'thread_type': ['discussion'], + 'title': ['Original Title'], + 'body': ['Original body'], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'closed': ['False'], + 'pinned': ['False'], + 'user_id': [str(self.user.id)], + 'read': ['False'] } @ddt.data(True, False) @@ -774,18 +676,18 @@ def test_update_all(self, read): } saved = self.save_and_reserialize(data, self.existing_thread) assert parsed_body(httpretty.last_request()) == { - "course_id": [str(self.course.id)], - "commentable_id": ["edited_topic"], - "thread_type": ["question"], - "title": ["Edited Title"], - "body": ["Edited body"], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "closed": ["False"], - "pinned": ["False"], - "user_id": [str(self.user.id)], - "read": [str(read)], - "editing_user_id": [str(self.user.id)], + 'course_id': [str(self.course.id)], + 'commentable_id': ['edited_topic'], + 'thread_type': ['question'], + 'title': ['Edited Title'], + 'body': ['Edited body'], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'closed': ['False'], + 'pinned': ['False'], + 'user_id': [str(self.user.id)], + 'read': [str(read)], + 'editing_user_id': [str(self.user.id)], } for key in data: assert saved[key] == data[key] @@ -800,7 +702,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] def test_update_anonymous_to_peers(self): """ @@ -812,7 +714,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] @ddt.data("", " ") def test_update_empty_string(self, value): @@ -820,12 +722,11 @@ def test_update_empty_string(self, value): self.existing_thread, data={field: value for field in ["topic_id", "title", "raw_body"]}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() assert serializer.errors == { - field: ["This field may not be blank."] - for field in ["topic_id", "title", "raw_body"] + field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] } def test_update_course_id(self): @@ -833,20 +734,15 @@ def test_update_course_id(self): self.existing_thread, data={"course_id": "some/other/course"}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() - assert serializer.errors == { - "course_id": ["This field is not allowed in an update."] - } + assert serializer.errors == {'course_id': ['This field is not allowed in an update.']} @ddt.ddt -class CommentSerializerDeserializationTest( - ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase -): +class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): """Tests for ThreadSerializer deserialization.""" - @classmethod def setUpClass(cls): super().setUpClass() @@ -859,8 +755,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -882,18 +778,14 @@ def setUp(self): "thread_id": "test_thread", "raw_body": "Test body", } - self.existing_comment = Comment( - **make_minimal_cs_comment( - { - "id": "existing_comment", - "thread_id": "dummy", - "body": "Original body", - "user_id": str(self.user.id), - "username": self.user.username, - "course_id": str(self.course.id), - } - ) - ) + self.existing_comment = Comment(**make_minimal_cs_comment({ + "id": "existing_comment", + "thread_id": "dummy", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "course_id": str(self.course.id), + })) def save_and_reserialize(self, data, instance=None): """ @@ -903,10 +795,13 @@ def save_and_reserialize(self, data, instance=None): context = get_context( self.course, self.request, - make_minimal_cs_thread({"course_id": str(self.course.id)}), + make_minimal_cs_thread({"course_id": str(self.course.id)}) ) serializer = CommentSerializer( - instance, data=data, partial=(instance is not None), context=context + instance, + data=data, + partial=(instance is not None), + context=context ) assert serializer.is_valid() serializer.save() @@ -918,23 +813,21 @@ def test_create_missing_field(self): data.pop(field) serializer = CommentSerializer( data=data, - context=get_context( - self.course, self.request, make_minimal_cs_thread() - ), + context=get_context(self.course, self.request, make_minimal_cs_thread()) ) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is required."]} + assert serializer.errors == {field: ['This field is required.']} def test_update_empty(self): self.register_put_comment_response(self.existing_comment.attributes) self.save_and_reserialize({}, instance=self.existing_comment) assert parsed_body(httpretty.last_request()) == { - "body": ["Original body"], - "course_id": [str(self.course.id)], - "user_id": [str(self.user.id)], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - "endorsed": ["False"], + 'body': ['Original body'], + 'course_id': [str(self.course.id)], + 'user_id': [str(self.user.id)], + 'anonymous': ['False'], + 'anonymous_to_peers': ['False'], + 'endorsed': ['False'] } def test_update_anonymous(self): @@ -947,7 +840,7 @@ def test_update_anonymous(self): "anonymous": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] def test_update_anonymous_to_peers(self): """ @@ -959,7 +852,7 @@ def test_update_anonymous_to_peers(self): "anonymous_to_peers": True, } self.save_and_reserialize(data, self.existing_comment) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ["True"] + assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] @ddt.data("thread_id", "parent_id") def test_update_non_updatable(self, field): @@ -967,26 +860,23 @@ def test_update_non_updatable(self, field): self.existing_comment, data={field: "different_value"}, partial=True, - context=get_context(self.course, self.request), + context=get_context(self.course, self.request) ) assert not serializer.is_valid() - assert serializer.errors == {field: ["This field is not allowed in an update."]} + assert serializer.errors == {field: ['This field is not allowed in an update.']} class FilterSpamTest(SharedModuleStoreTestCase): """ Tests for the filter_spam method """ - - @override_settings(DISCUSSION_SPAM_URLS=["example.com"]) + @override_settings(DISCUSSION_SPAM_URLS=['example.com']) def test_filter(self): self.assertEqual( - filter_spam_urls_from_html( - '' - )[0], - "
abc
", + filter_spam_urls_from_html('')[0], + '
abc
' ) self.assertEqual( - filter_spam_urls_from_html("
example.com/abc/def
")[0], - "
", + filter_spam_urls_from_html('
example.com/abc/def
')[0], + '
' ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 8a9405076c2d..e4d46168c46d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2,6 +2,7 @@ Tests for Discussion API views """ + import json import random from datetime import datetime @@ -19,22 +20,22 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls + from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.models import ( - CourseEnrollment, - get_retired_username_by_username, -) -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, - GlobalStaff, -) +from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.student.tests.factories import ( AdminFactory, CourseEnrollmentFactory, SuperuserFactory, - UserFactory, + UserFactory ) from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -47,50 +48,21 @@ make_minimal_cs_comment, make_minimal_cs_thread, ) -from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ( - ENABLE_NEW_STRUCTURE_DISCUSSIONS, -) -from openedx.core.djangoapps.discussions.models import ( - DiscussionsConfiguration, - DiscussionTopicLink, - Provider, -) -from openedx.core.djangoapps.discussions.tasks import ( - update_discussions_settings_from_course_task, -) +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider +from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, Role, ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( - AccessTokenFactory, - ApplicationFactory, -) -from openedx.core.djangoapps.user_api.models import ( - RetirementState, - UserRetirementStatus, -) -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import ( - BlockFactory, - CourseFactory, - check_mongo_calls, -) +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus -class DiscussionAPIViewTestMixin( - ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin -): +class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): """ Mixin for common code in tests of Discussion API views. This includes creation of common structures (e.g. a course, user, and enrollment), logging @@ -100,9 +72,7 @@ class DiscussionAPIViewTestMixin( client_class = APIClient - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.maxDiff = None # pylint: disable=invalid-name @@ -111,7 +81,7 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}}, + discussion_topics={"Test Topic": {"id": "test_topic"}} ) self.password = "Password1234" self.user = UserFactory.create(password=self.password) @@ -126,25 +96,23 @@ def assert_response_correct(self, response, expected_status, expected_content): Assert that the response has the given status code and parsed content """ assert response.status_code == expected_status - parsed_content = json.loads(response.content.decode("utf-8")) + parsed_content = json.loads(response.content.decode('utf-8')) assert parsed_content == expected_content def register_thread(self, overrides=None): """ Create cs_thread with minimal fields and register response """ - cs_thread = make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": "Test Title", - "body": "Test body", - } - ) + cs_thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + }) cs_thread.update(overrides or {}) self.register_get_thread_response(cs_thread) self.register_put_thread_response(cs_thread) @@ -153,16 +121,14 @@ def register_comment(self, overrides=None): """ Create cs_comment with minimal fields and register response """ - cs_comment = make_minimal_cs_comment( - { - "id": "test_comment", - "course_id": str(self.course.id), - "thread_id": "test_thread", - "username": self.user.username, - "user_id": str(self.user.id), - "body": "Original body", - } - ) + cs_comment = make_minimal_cs_comment({ + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + }) cs_comment.update(overrides or {}) self.register_get_comment_response(cs_comment) self.register_put_comment_response(cs_comment) @@ -174,7 +140,7 @@ def test_not_authenticated(self): self.assert_response_correct( response, 401, - {"developer_message": "Authentication credentials were not provided."}, + {"developer_message": "Authentication credentials were not provided."} ) def test_inactive(self): @@ -183,16 +149,12 @@ def test_inactive(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class UploadFileViewTest( - ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase -): +class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase): """ Tests for UploadFileView. """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.valid_file = { @@ -203,13 +165,11 @@ def setUp(self): ), } self.user = UserFactory.create(password=self.TEST_PASSWORD) - self.course = CourseFactory.create( - org="a", course="b", run="c", start=datetime.now(UTC) - ) + self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -297,13 +257,10 @@ def test_file_upload_with_thread_key(self): """ self.user_login() self.enroll_user_in_course() - response = self.client.post( - self.url, - { - **self.valid_file, - "thread_key": "somethread", - }, - ) + response = self.client.post(self.url, { + **self.valid_file, + "thread_key": "somethread", + }) response_data = json.loads(response.content) assert "/somethread/" in response_data["location"] @@ -357,9 +314,7 @@ class CommentViewSetListByUserTest( Common test cases for views retrieving user-published content. """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() @@ -368,8 +323,8 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -380,9 +335,7 @@ def setUp(self): self.other_user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.other_user) - self.course = CourseFactory.create( - org="a", course="b", run="c", start=datetime.now(UTC) - ) + self.course = CourseFactory.create(org="a", course="b", run="c", start=datetime.now(UTC)) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) self.url = self.build_url(self.user.username, self.course.id) @@ -393,18 +346,16 @@ def register_mock_endpoints(self): """ self.register_get_threads_response( threads=[ - make_minimal_cs_thread( - { - "id": f"test_thread_{index}", - "course_id": str(self.course.id), - "commentable_id": f"test_topic_{index}", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": f"Test Title #{index}", - "body": f"Test body #{index}", - } - ) + make_minimal_cs_thread({ + "id": f"test_thread_{index}", + "course_id": str(self.course.id), + "commentable_id": f"test_topic_{index}", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": f"Test Title #{index}", + "body": f"Test body #{index}", + }) for index in range(30) ], page=1, @@ -412,18 +363,16 @@ def register_mock_endpoints(self): ) self.register_get_comments_response( comments=[ - make_minimal_cs_comment( - { - "id": f"test_comment_{index}", - "thread_id": "test_thread", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "body": f"Test body #{index}", - "votes": {"up_count": 4}, - } - ) + make_minimal_cs_comment({ + "id": f"test_comment_{index}", + "thread_id": "test_thread", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": f"Test body #{index}", + "votes": {"up_count": 4}, + }) for index in range(30) ], page=1, @@ -435,13 +384,11 @@ def build_url(self, username, course_id, **kwargs): Builds an URL to access content from an user on a specific course. """ base = reverse("comment-list") - query = urlencode( - { - "username": username, - "course_id": str(course_id), - **kwargs, - } - ) + query = urlencode({ + "username": username, + "course_id": str(course_id), + **kwargs, + }) return f"{base}?{query}" def assert_successful_response(self, response): @@ -467,9 +414,7 @@ def test_request_by_unauthorized_user(self): they're not either enrolled or staff members. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == status.HTTP_404_NOT_FOUND assert json.loads(response.content)["developer_message"] == "Course not found." @@ -480,9 +425,7 @@ def test_request_by_enrolled_user(self): comments in that course. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id) self.assert_successful_response(self.client.get(self.url)) @@ -491,9 +434,7 @@ def test_request_by_global_staff(self): Staff users are allowed to get any user's comments. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -504,9 +445,7 @@ def test_request_by_course_staff(self, role): course. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) role(course_key=self.course.id).add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -515,9 +454,7 @@ def test_request_with_non_existent_user(self): Requests for users that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url("non_existent", self.course.id) response = self.client.get(url) @@ -528,9 +465,7 @@ def test_request_with_non_existent_course(self): Requests for courses that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "course-v1:x+y+z") response = self.client.get(url) @@ -541,18 +476,14 @@ def test_request_with_invalid_course_id(self): Requests with invalid course ID should fail form validation. """ self.register_mock_endpoints() - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "an invalid course") response = self.client.get(url) assert response.status_code == status.HTTP_400_BAD_REQUEST parsed_response = json.loads(response.content) - assert ( - parsed_response["field_errors"]["course_id"]["developer_message"] - == "'an invalid course' is not a valid course id" - ) + assert parsed_response["field_errors"]["course_id"]["developer_message"] == \ + "'an invalid course' is not a valid course id" def test_request_with_empty_results_page(self): """ @@ -562,9 +493,7 @@ def test_request_with_empty_results_page(self): self.register_get_threads_response(threads=[], page=1, num_pages=1) self.register_get_comments_response(comments=[], page=1, num_pages=1) - self.client.login( - username=self.other_user.username, password=self.TEST_PASSWORD - ) + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, self.course.id, page=2) response = self.client.get(url) @@ -572,23 +501,17 @@ def test_request_with_empty_results_page(self): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -@override_settings( - DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"} -) -@override_settings( - DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"} -) +@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"}) +@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"}) class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for CourseView""" def setUp(self): super().setUp() - self.url = reverse( - "discussion_course", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -598,7 +521,9 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -622,27 +547,23 @@ def test_basic(self): "allow_anonymous_to_peers": False, "has_bulk_delete_privileges": False, "has_moderation_privileges": False, - "is_course_admin": False, - "is_course_staff": False, + 'is_course_admin': False, + 'is_course_staff': False, "is_group_ta": False, - "is_user_admin": False, + 'is_user_admin': False, "user_roles": ["Student"], - "edit_reasons": [ - {"code": "test-edit-reason", "label": "Test Edit Reason"} - ], - "post_close_reasons": [ - {"code": "test-close-reason", "label": "Test Close Reason"} - ], - "show_discussions": True, - "is_notify_all_learners_enabled": False, - "captcha_settings": { - "enabled": False, - "site_key": None, + "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], + "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], + 'show_discussions': True, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': None, }, "is_email_verified": True, "only_verified_users_can_post": False, - "content_creation_rate_limited": False, - }, + "content_creation_rate_limited": False + } ) @@ -653,10 +574,8 @@ class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - RetirementState.objects.create(state_name="PENDING", state_execution_order=1) - self.retire_forums_state = RetirementState.objects.create( - state_name="RETIRE_FORUMS", state_execution_order=11 - ) + RetirementState.objects.create(state_name='PENDING', state_execution_order=1) + self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11) self.retirement = UserRetirementStatus.create_retirement(self.user) self.retirement.current_state = self.retire_forums_state @@ -667,8 +586,8 @@ def setUp(self): self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -680,14 +599,14 @@ def assert_response_correct(self, response, expected_status, expected_content): assert response.status_code == expected_status if expected_content: - assert response.content.decode("utf-8") == expected_content + assert response.content.decode('utf-8') == expected_content def build_jwt_headers(self, user): """ Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {"HTTP_AUTHORIZATION": "JWT " + token} + headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} return headers def test_basic(self): @@ -696,7 +615,7 @@ def test_basic(self): """ self.register_get_user_retire_response(self.user) headers = self.build_jwt_headers(self.superuser) - data = {"username": self.user.username} + data = {'username': self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 204, b"") @@ -704,11 +623,9 @@ def test_downstream_forums_error(self): """ Check that we bubble up errors from the comments service """ - self.register_get_user_retire_response( - self.user, status=500, body="Server error" - ) + self.register_get_user_retire_response(self.user, status=500, body="Server error") headers = self.build_jwt_headers(self.superuser) - data = {"username": self.user.username} + data = {'username': self.user.username} response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 500, '"Server error"') @@ -718,7 +635,7 @@ def test_nonexistent_user(self): """ nonexistent_username = "nonexistent user" self.retired_username = get_retired_username_by_username(nonexistent_username) - data = {"username": nonexistent_username} + data = {'username': nonexistent_username} headers = self.build_jwt_headers(self.superuser) response = self.superuser_client.post(self.url, data, **headers) self.assert_response_correct(response, 404, None) @@ -732,10 +649,7 @@ def test_not_authenticated(self): @ddt.ddt @httpretty.activate -@mock.patch( - "django.conf.settings.USERNAME_REPLACEMENT_WORKER", - "test_replace_username_service_worker", -) +@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for ReplaceUsernamesView""" @@ -748,8 +662,8 @@ def setUp(self): self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -768,28 +682,34 @@ def build_jwt_headers(self, user): Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) - headers = {"HTTP_AUTHORIZATION": "JWT " + token} + headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} return headers def call_api(self, user, client, data): - """Helper function to call API with data""" + """ Helper function to call API with data """ data = json.dumps(data) headers = self.build_jwt_headers(user) - return client.post(self.url, data, content_type="application/json", **headers) + return client.post(self.url, data, content_type='application/json', **headers) - @ddt.data([{}, {}], {}, [{"test_key": "test_value", "test_key_2": "test_value_2"}]) + @ddt.data( + [{}, {}], + {}, + [{"test_key": "test_value", "test_key_2": "test_value_2"}] + ) def test_bad_schema(self, mapping_data): - """Verify the endpoint rejects bad data schema""" - data = {"username_mappings": mapping_data} + """ Verify the endpoint rejects bad data schema """ + data = { + "username_mappings": mapping_data + } response = self.call_api(self.worker, self.worker_client, data) assert response.status_code == 400 def test_auth(self): - """Verify the endpoint only works with the service worker""" + """ Verify the endpoint only works with the service worker """ data = { "username_mappings": [ {"test_username_1": "test_new_username_1"}, - {"test_username_2": "test_new_username_2"}, + {"test_username_2": "test_new_username_2"} ] } @@ -807,15 +727,15 @@ def test_auth(self): assert response.status_code == 200 def test_basic(self): - """Check successful replacement""" + """ Check successful replacement """ data = { "username_mappings": [ {self.user.username: self.new_username}, ] } expected_response = { - "failed_replacements": [], - "successful_replacements": data["username_mappings"], + 'failed_replacements': [], + 'successful_replacements': data["username_mappings"] } self.register_get_username_replacement_response(self.user) response = self.call_api(self.worker, self.worker_client, data) @@ -831,9 +751,7 @@ def test_not_authenticated(self): @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CourseTopicsViewTest( - DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase -): +class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): """ Tests for CourseTopicsView """ @@ -850,12 +768,10 @@ def setUp(self): "courseware-2": {"discussion": 4, "question": 5}, "courseware-3": {"discussion": 7, "question": 2}, } - self.register_get_course_commentable_counts_response( - self.course.id, self.thread_counts_map - ) + self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -870,7 +786,7 @@ def create_course(self, blocks_count, module_store, topics): run="c", start=datetime.now(UTC), default_store=module_store, - discussion_topics=topics, + discussion_topics=topics ) CourseEnrollmentFactory.create(user=self.user, course_id=course.id) course_url = reverse("course_topics", kwargs={"course_id": str(course.id)}) @@ -878,10 +794,10 @@ def create_course(self, blocks_count, module_store, topics): for i in range(blocks_count): BlockFactory.create( parent_location=course.location, - category="discussion", - discussion_id=f"id_module_{i}", - discussion_category=f"Category {i}", - discussion_target=f"Discussion {i}", + category='discussion', + discussion_id=f'id_module_{i}', + discussion_category=f'Category {i}', + discussion_target=f'Discussion {i}', publish_item=False, ) return course_url, course.id @@ -896,7 +812,7 @@ def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): discussion_id=topic_id, discussion_category=category, discussion_target=subcategory, - **kwargs, + **kwargs ) def test_404(self): @@ -904,7 +820,9 @@ def test_404(self): reverse("course_topics", kwargs={"course_id": "non/existent/course"}) ) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -914,30 +832,21 @@ def test_basic(self): 200, { "courseware_topics": [], - "non_courseware_topics": [ - { - "id": "test_topic", - "name": "Test Topic", - "children": [], - "thread_list_url": "http://testserver/api/discussion/v1/threads/" - "?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic", - "thread_counts": {"discussion": 0, "question": 0}, - } - ], - }, + "non_courseware_topics": [{ + "id": "test_topic", + "name": "Test Topic", + "children": [], + "thread_list_url": 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic', + "thread_counts": {"discussion": 0, "question": 0}, + }], + } ) @ddt.data( (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), - ( - 2, - ModuleStoreEnum.Type.split, - 2, - { - "Test Topic 1": {"id": "test_topic_1"}, - "Test Topic 2": {"id": "test_topic_2"}, - }, - ), + (2, ModuleStoreEnum.Type.split, 2, + {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}), (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), ) @ddt.unpack @@ -959,7 +868,7 @@ def test_discussion_topic_404(self): self.assert_response_correct( response, 404, - {"developer_message": "Discussion not found for 'invalid_topic_id'."}, + {"developer_message": "Discussion not found for 'invalid_topic_id'."} ) def test_topic_id(self): @@ -979,41 +888,38 @@ def test_topic_id(self): "non_courseware_topics": [], "courseware_topics": [ { - "children": [ - { - "children": [], - "id": "topic_id_1", - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", - "name": "test_target_1", - "thread_counts": {"discussion": 0, "question": 0}, - } - ], + "children": [{ + "children": [], + "id": "topic_id_1", + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "name": "test_target_1", + "thread_counts": {"discussion": 0, "question": 0}, + }], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", "name": "test_category_1", "thread_counts": None, }, { - "children": [ - { + "children": + [{ "children": [], "id": "topic_id_2", "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_target_2", "thread_counts": {"discussion": 0, "question": 0}, - } - ], + }], "id": None, "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", "name": "test_category_2", "thread_counts": None, - }, - ], - }, + } + ] + } ) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) @@ -1024,46 +930,45 @@ def test_new_course_structure_response(self): """ chapter = BlockFactory.create( parent_location=self.course.location, - category="chapter", + category='chapter', display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) sequential = BlockFactory.create( parent_location=chapter.location, - category="sequential", + category='sequential', display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) BlockFactory.create( parent_location=sequential.location, - category="vertical", - display_name="vertical", + category='vertical', + display_name='vertical', start=datetime(2015, 4, 1, tzinfo=UTC), ) DiscussionsConfiguration.objects.create( - context_key=self.course.id, provider_type=Provider.OPEN_EDX + context_key=self.course.id, + provider_type=Provider.OPEN_EDX ) update_discussions_settings_from_course_task(str(self.course.id)) response = json.loads(self.client.get(self.url).content.decode()) - keys = ["children", "id", "name", "thread_counts", "thread_list_url"] - assert list(response.keys()) == ["courseware_topics", "non_courseware_topics"] - assert len(response["courseware_topics"]) == 1 - courseware_keys = list(response["courseware_topics"][0].keys()) + keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url'] + assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics'] + assert len(response['courseware_topics']) == 1 + courseware_keys = list(response['courseware_topics'][0].keys()) courseware_keys.sort() assert courseware_keys == keys - assert len(response["non_courseware_topics"]) == 1 - non_courseware_keys = list(response["non_courseware_topics"][0].keys()) + assert len(response['non_courseware_topics']) == 1 + non_courseware_keys = list(response['non_courseware_topics'][0].keys()) non_courseware_keys.sort() assert non_courseware_keys == keys @ddt.ddt -@mock.patch("lms.djangoapps.discussion.rest_api.api._get_course", mock.Mock()) +@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock()) @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) -class CourseTopicsViewV3Test( - DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase -): +class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): """ Tests for CourseTopicsViewV3 """ @@ -1079,68 +984,55 @@ def setUp(self) -> None: end=datetime(2028, 1, 1), enrollment_start=datetime(2020, 1, 1), enrollment_end=datetime(2028, 1, 1), - discussion_topics={ - "Course Wide Topic": { - "id": "course-wide-topic", - "usage_key": None, - } - }, + discussion_topics={"Course Wide Topic": { + "id": 'course-wide-topic', + "usage_key": None, + }} ) self.chapter = BlockFactory.create( parent_location=self.course.location, - category="chapter", + category='chapter', display_name="Week 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.sequential = BlockFactory.create( parent_location=self.chapter.location, - category="sequential", + category='sequential', display_name="Lesson 1", start=datetime(2015, 3, 1, tzinfo=UTC), ) self.verticals = [ BlockFactory.create( parent_location=self.sequential.location, - category="vertical", - display_name="vertical", + category='vertical', + display_name='vertical', start=datetime(2015, 4, 1, tzinfo=UTC), ) ] course_key = self.course.id - self.config = DiscussionsConfiguration.objects.create( - context_key=course_key, provider_type=Provider.OPEN_EDX - ) + self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX) topic_links = [] update_discussions_settings_from_course_task(str(course_key)) - topic_id_query = DiscussionTopicLink.objects.filter( - context_key=course_key - ).values_list( - "external_id", - flat=True, + topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list( + 'external_id', flat=True, ) - topic_ids = list(topic_id_query.order_by("ordering")) + topic_ids = list(topic_id_query.order_by('ordering')) DiscussionTopicLink.objects.bulk_create(topic_links) self.topic_stats = { - **{ - topic_id: dict( - discussion=random.randint(0, 10), question=random.randint(0, 10) - ) - for topic_id in set(topic_ids) - }, + **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10)) + for topic_id in set(topic_ids)}, topic_ids[0]: dict(discussion=0, question=0), } patcher = mock.patch( - "lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts", + 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', mock.Mock(return_value=self.topic_stats), ) patcher.start() self.addCleanup(patcher.stop) - self.url = reverse( - "course_topics_v3", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1149,23 +1041,12 @@ def test_basic(self): response = self.client.get(self.url) data = json.loads(response.content.decode()) expected_non_courseware_keys = [ - "id", - "usage_key", - "name", - "thread_counts", - "enabled_in_context", - "courseware", + 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context', + 'courseware' ] expected_courseware_keys = [ - "id", - "block_id", - "lms_web_url", - "legacy_web_url", - "student_view_url", - "type", - "display_name", - "children", - "courseware", + 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url', + 'type', 'display_name', 'children', 'courseware' ] assert response.status_code == 200 assert len(data) == 2 @@ -1173,11 +1054,11 @@ def test_basic(self): assert non_courseware_topic_keys == expected_non_courseware_keys courseware_topic_keys = list(data[1].keys()) assert courseware_topic_keys == expected_courseware_keys - expected_courseware_keys.remove("courseware") - sequential_keys = list(data[1]["children"][0].keys()) - assert sequential_keys == (expected_courseware_keys + ["thread_counts"]) - expected_non_courseware_keys.remove("courseware") - vertical_keys = list(data[1]["children"][0]["children"][0].keys()) + expected_courseware_keys.remove('courseware') + sequential_keys = list(data[1]['children'][0].keys()) + assert sequential_keys == (expected_courseware_keys + ['thread_counts']) + expected_non_courseware_keys.remove('courseware') + vertical_keys = list(data[1]['children'][0]['children'][0].keys()) assert vertical_keys == expected_non_courseware_keys @@ -1218,21 +1099,14 @@ def setUp(self): {"key": "close_reason", "value": None}, { "key": "comment_list_url", - "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread" }, { "key": "editable_fields", "value": [ - "abuse_flagged", - "anonymous", - "copy_link", - "following", - "raw_body", - "read", - "title", - "topic_id", - "type", - ], + 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', + 'read', 'title', 'topic_id', 'type' + ] }, {"key": "endorsed_comment_list_url", "value": None}, {"key": "following", "value": False}, @@ -1243,39 +1117,32 @@ def setUp(self): {"key": "non_endorsed_comment_list_url", "value": None}, {"key": "preview_body", "value": "Test body"}, {"key": "raw_body", "value": "Test body"}, + {"key": "rendered_body", "value": "

Test body

"}, {"key": "response_count", "value": 0}, {"key": "topic_id", "value": "test_topic"}, {"key": "type", "value": "discussion"}, - { - "key": "users", - "value": { - self.user.username: { - "profile": { - "image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - } + {"key": "users", "value": { + self.user.username: { + "profile": { + "image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", } } - }, - }, + } + }}, {"key": "vote_count", "value": 4}, {"key": "voted", "value": False}, - {"key": "is_deleted", "value": None}, - {"key": "deleted_at", "value": None}, - {"key": "deleted_by", "value": None}, - {"key": "deleted_by_label", "value": None}, + ] - self.url = reverse( - "discussion_learner_threads", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1286,12 +1153,12 @@ def update_thread(self, thread): Value of these keys has been defined in setUp function """ for element in self.add_keys: - thread[element["key"]] = element["value"] + thread[element['key']] = element['value'] for pair in self.replace_keys: - thread[pair["to"]] = thread.pop(pair["from"]) + thread[pair['to']] = thread.pop(pair['from']) for key in self.remove_keys: thread.pop(key) - thread["comment_count"] += 1 + thread['comment_count'] += 1 return thread def test_basic(self): @@ -1303,26 +1170,22 @@ def test_basic(self): """ self.register_get_user_response(self.user) expected_cs_comments_response = { - "collection": [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "closed_by_label": None, - "edit_by_label": None, - } - ) - ], + "collection": [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by_label": None, + "edit_by_label": None, + })], "page": 1, "num_pages": 1, } @@ -1330,14 +1193,14 @@ def test_basic(self): self.url += f"?username={self.user.username}" response = self.client.get(self.url) assert response.status_code == 200 - response_data = json.loads(response.content.decode("utf-8")) - expected_api_response = expected_cs_comments_response["collection"] + response_data = json.loads(response.content.decode('utf-8')) + expected_api_response = expected_cs_comments_response['collection'] for thread in expected_api_response: self.update_thread(thread) - assert response_data["results"] == expected_api_response - assert response_data["pagination"] == { + assert response_data['results'] == expected_api_response + assert response_data['pagination'] == { "next": None, "previous": None, "count": 1, @@ -1367,24 +1230,20 @@ def test_thread_type_by(self, thread_type): thread_type (str): Value of thread_type can be 'None', 'discussion' and 'question' """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1398,26 +1257,23 @@ def test_thread_type_by(self, thread_type): "course_id": str(self.course.id), "username": self.user.username, "thread_type": thread_type, - }, - ) - assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - "sort_key": ["activity"], - "count_flagged": ["False"], - "show_deleted": ["False"], } ) + assert response.status_code == 200 + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "thread_type": [thread_type], + "sort_key": ['activity'], + "count_flagged": ["False"] + }) @ddt.data( ("last_activity_at", "activity"), ("comment_count", "comments"), - ("vote_count", "votes"), + ("vote_count", "votes") ) @ddt.unpack def test_order_by(self, http_query, cc_query): @@ -1428,24 +1284,20 @@ def test_order_by(self, http_query, cc_query): http_query (str): Query string sent in the http request cc_query (str): Query string used for the comments client service """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1459,20 +1311,17 @@ def test_order_by(self, http_query, cc_query): "course_id": str(self.course.id), "username": self.user.username, "order_by": http_query, - }, - ) - assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - "count_flagged": ["False"], - "show_deleted": ["False"], } ) + assert response.status_code == 200 + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + "sort_key": [cc_query], + "count_flagged": ["False"] + }) @ddt.data("flagged", "unanswered", "unread", "unresponded") def test_status_by(self, post_status): @@ -1483,24 +1332,20 @@ def test_status_by(self, post_status): post_status (str): Value of post_status can be 'flagged', 'unanswered' and 'unread' """ - threads = [ - make_minimal_cs_thread( - { - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - } - ) - ] + threads = [make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + })] expected_cs_comments_response = { "collection": threads, "page": 1, @@ -1514,37 +1359,29 @@ def test_status_by(self, post_status): "course_id": str(self.course.id), "username": self.user.username, "status": post_status, - }, + } ) if post_status == "flagged": assert response.status_code == 403 else: assert response.status_code == 200 - self.assert_last_query_params( - { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - post_status: ["True"], - "sort_key": ["activity"], - "count_flagged": ["False"], - "show_deleted": ["False"], - } - ) + self.assert_last_query_params({ + "user_id": [str(self.user.id)], + "course_id": [str(self.course.id)], + "page": ["1"], + "per_page": ["10"], + post_status: ['True'], + "sort_key": ['activity'], + "count_flagged": ["False"] + }) @ddt.ddt -class CourseDiscussionSettingsAPIViewTest( - APITestCase, UrlResetMixin, ModuleStoreTestCase -): +class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): """ Test the course discussion settings handler API endpoint. """ - - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() self.course = CourseFactory.create( @@ -1552,26 +1389,24 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}}, - ) - self.path = reverse( - "discussion_course_settings", kwargs={"course_id": str(self.course.id)} + discussion_topics={"Test Topic": {"id": "test_topic"}} ) + self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD - self.user = UserFactory(username="staff", password=self.password, is_staff=True) + self.user = UserFactory(username='staff', password=self.password, is_staff=True) patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" - access_token = AccessTokenFactory.create( - user=user, application=ApplicationFactory() - ).token - headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } return headers def _login_as_staff(self): @@ -1579,30 +1414,24 @@ def _login_as_staff(self): self.client.login(username=self.user.username, password=self.password) def _login_as_discussion_staff(self): - user = UserFactory(username="abc", password="abc") - role = Role.objects.create(name="Administrator", course_id=self.course.id) + user = UserFactory(username='abc', password='abc') + role = Role.objects.create(name='Administrator', course_id=self.course.id) role.users.set([user]) - self.client.login(username=user.username, password="abc") + self.client.login(username=user.username, password='abc') def _create_divided_discussions(self): """Create some divided discussions for testing.""" - divided_inline_discussions = [ - "Topic A", - ] - divided_course_wide_discussions = [ - "Topic B", - ] - divided_discussions = ( - divided_inline_discussions + divided_course_wide_discussions - ) + divided_inline_discussions = ['Topic A', ] + divided_course_wide_discussions = ['Topic B', ] + divided_discussions = divided_inline_discussions + divided_course_wide_discussions BlockFactory.create( parent=self.course, - category="discussion", - discussion_id=topic_name_to_id(self.course, "Topic A"), - discussion_category="Chapter", - discussion_target="Discussion", - start=datetime.now(), + category='discussion', + discussion_id=topic_name_to_id(self.course, 'Topic A'), + discussion_category='Chapter', + discussion_target='Discussion', + start=datetime.now() ) discussion_topics = { "Topic B": {"id": "Topic B"}, @@ -1611,36 +1440,31 @@ def _create_divided_discussions(self): config_course_discussions( self.course, discussion_topics=discussion_topics, - divided_discussions=divided_discussions, + divided_discussions=divided_discussions ) return divided_inline_discussions, divided_course_wide_discussions def _get_expected_response(self): """Return the default expected response before any changes to the discussion settings.""" return { - "always_divide_inline_discussions": False, - "divided_inline_discussions": [], - "divided_course_wide_discussions": [], - "id": 1, - "division_scheme": "cohort", - "available_division_schemes": ["cohort"], - "reported_content_email_notifications": False, + 'always_divide_inline_discussions': False, + 'divided_inline_discussions': [], + 'divided_course_wide_discussions': [], + 'id': 1, + 'division_scheme': 'cohort', + 'available_division_schemes': ['cohort'], + 'reported_content_email_notifications': False, } def patch_request(self, data, headers=None): headers = headers if headers else {} - return self.client.patch( - self.path, - json.dumps(data), - content_type="application/merge-patch+json", - **headers, - ) + return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers) def _assert_current_settings(self, expected_response): """Validate the current discussion settings against the expected response.""" response = self.client.get(self.path) assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def _assert_patched_settings(self, data, expected_response): @@ -1649,7 +1473,7 @@ def _assert_patched_settings(self, data, expected_response): assert response.status_code == 204 self._assert_current_settings(expected_response) - @ddt.data("get", "patch") + @ddt.data('get', 'patch') def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1657,8 +1481,8 @@ def test_authentication_required(self, method): assert response.status_code == 401 @ddt.data( - {"is_staff": False, "get_status": 403, "put_status": 403}, - {"is_staff": True, "get_status": 200, "put_status": 204}, + {'is_staff': False, 'get_status': 403, 'put_status': 403}, + {'is_staff': True, 'get_status': 200, 'put_status': 204}, ) @ddt.unpack def test_oauth(self, is_staff, get_status, put_status): @@ -1671,7 +1495,7 @@ def test_oauth(self, is_staff, get_status, put_status): assert response.status_code == get_status response = self.patch_request( - {"always_divide_inline_discussions": True}, headers + {'always_divide_inline_discussions': True}, headers ) assert response.status_code == put_status @@ -1679,68 +1503,66 @@ def test_non_existent_course_id(self): """Test the response when this endpoint is passed a non-existent course id.""" self._login_as_staff() response = self.client.get( - reverse( - "discussion_course_settings", kwargs={"course_id": "course-v1:a+b+c"} - ) + reverse('discussion_course_settings', kwargs={ + 'course_id': 'course-v1:a+b+c' + }) ) assert response.status_code == 404 def test_patch_request_by_discussion_staff(self): """Test the response when patch request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - response = self.patch_request({"always_divide_inline_discussions": True}) + response = self.patch_request( + {'always_divide_inline_discussions': True} + ) assert response.status_code == 403 def test_get_request_by_discussion_staff(self): """Test the response when get request is sent by a user with discussions staff role.""" self._login_as_discussion_staff() - divided_inline_discussions, divided_course_wide_discussions = ( - self._create_divided_discussions() - ) + divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response["divided_course_wide_discussions"] = [ - topic_name_to_id(self.course, name) - for name in divided_course_wide_discussions + expected_response['divided_course_wide_discussions'] = [ + topic_name_to_id(self.course, name) for name in divided_course_wide_discussions ] - expected_response["divided_inline_discussions"] = [ + expected_response['divided_inline_discussions'] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def test_get_request_by_non_staff_user(self): """Test the response when get request is sent by a regular user with no staff role.""" - user = UserFactory(username="abc", password="abc") - self.client.login(username=user.username, password="abc") + user = UserFactory(username='abc', password='abc') + self.client.login(username=user.username, password='abc') response = self.client.get(self.path) assert response.status_code == 403 def test_patch_request_by_non_staff_user(self): """Test the response when patch request is sent by a regular user with no staff role.""" - user = UserFactory(username="abc", password="abc") - self.client.login(username=user.username, password="abc") - response = self.patch_request({"always_divide_inline_discussions": True}) + user = UserFactory(username='abc', password='abc') + self.client.login(username=user.username, password='abc') + response = self.patch_request( + {'always_divide_inline_discussions': True} + ) assert response.status_code == 403 def test_get_settings(self): """Test the current discussion settings against the expected response.""" - divided_inline_discussions, divided_course_wide_discussions = ( - self._create_divided_discussions() - ) + divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions() self._login_as_staff() response = self.client.get(self.path) assert response.status_code == 200 expected_response = self._get_expected_response() - expected_response["divided_course_wide_discussions"] = [ - topic_name_to_id(self.course, name) - for name in divided_course_wide_discussions + expected_response['divided_course_wide_discussions'] = [ + topic_name_to_id(self.course, name) for name in divided_course_wide_discussions ] - expected_response["divided_inline_discussions"] = [ + expected_response['divided_inline_discussions'] = [ topic_name_to_id(self.course, name) for name in divided_inline_discussions ] - content = json.loads(response.content.decode("utf-8")) + content = json.loads(response.content.decode('utf-8')) assert content == expected_response def test_available_schemes(self): @@ -1748,23 +1570,18 @@ def test_available_schemes(self): config_course_cohorts(self.course, is_cohorted=False) self._login_as_staff() expected_response = self._get_expected_response() - expected_response["available_division_schemes"] = [] + expected_response['available_division_schemes'] = [] self._assert_current_settings(expected_response) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) - CourseModeFactory.create( - course_id=self.course.id, mode_slug=CourseMode.VERIFIED - ) + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) - expected_response["available_division_schemes"] = [ - CourseDiscussionSettings.ENROLLMENT_TRACK - ] + expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK] self._assert_current_settings(expected_response) config_course_cohorts(self.course, is_cohorted=True) - expected_response["available_division_schemes"] = [ - CourseDiscussionSettings.COHORT, - CourseDiscussionSettings.ENROLLMENT_TRACK, + expected_response['available_division_schemes'] = [ + CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK ] self._assert_current_settings(expected_response) @@ -1778,11 +1595,11 @@ def test_empty_body_patch_request(self): assert response.status_code == 400 @ddt.data( - {"abc": 123}, - {"divided_course_wide_discussions": 3}, - {"divided_inline_discussions": "a"}, - {"always_divide_inline_discussions": ["a"]}, - {"division_scheme": True}, + {'abc': 123}, + {'divided_course_wide_discussions': 3}, + {'divided_inline_discussions': 'a'}, + {'always_divide_inline_discussions': ['a']}, + {'division_scheme': True} ) def test_invalid_body_parameters(self, body): """Test the response status code on sending a PATCH request with parameters having incorrect types.""" @@ -1796,34 +1613,31 @@ def test_update_always_divide_inline_discussion_settings(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response["always_divide_inline_discussions"] = True + expected_response['always_divide_inline_discussions'] = True - self._assert_patched_settings( - {"always_divide_inline_discussions": True}, expected_response - ) + self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response) def test_update_course_wide_discussion_settings(self): """Test whether the 'divided_course_wide_discussions' setting is updated.""" - discussion_topics = {"Topic B": {"id": "Topic B"}} + discussion_topics = { + 'Topic B': {'id': 'Topic B'} + } config_course_cohorts(self.course, is_cohorted=True) config_course_discussions(self.course, discussion_topics=discussion_topics) expected_response = self._get_expected_response() self._login_as_staff() self._assert_current_settings(expected_response) - expected_response["divided_course_wide_discussions"] = [ + expected_response['divided_course_wide_discussions'] = [ topic_name_to_id(self.course, "Topic B") ] self._assert_patched_settings( - { - "divided_course_wide_discussions": [ - topic_name_to_id(self.course, "Topic B") - ] - }, - expected_response, + {'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]}, + expected_response ) - expected_response["divided_course_wide_discussions"] = [] + expected_response['divided_course_wide_discussions'] = [] self._assert_patched_settings( - {"divided_course_wide_discussions": []}, expected_response + {'divided_course_wide_discussions': []}, + expected_response ) def test_update_inline_discussion_settings(self): @@ -1836,23 +1650,17 @@ def test_update_inline_discussion_settings(self): now = datetime.now() BlockFactory.create( parent_location=self.course.location, - category="discussion", - discussion_id="Topic_A", - discussion_category="Chapter", - discussion_target="Discussion", - start=now, - ) - expected_response["divided_inline_discussions"] = [ - "Topic_A", - ] - self._assert_patched_settings( - {"divided_inline_discussions": ["Topic_A"]}, expected_response + category='discussion', + discussion_id='Topic_A', + discussion_category='Chapter', + discussion_target='Discussion', + start=now ) + expected_response['divided_inline_discussions'] = ['Topic_A', ] + self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response) - expected_response["divided_inline_discussions"] = [] - self._assert_patched_settings( - {"divided_inline_discussions": []}, expected_response - ) + expected_response['divided_inline_discussions'] = [] + self._assert_patched_settings({'divided_inline_discussions': []}, expected_response) def test_update_division_scheme(self): """Test whether the 'division_scheme' setting is updated.""" @@ -1860,17 +1668,15 @@ def test_update_division_scheme(self): self._login_as_staff() expected_response = self._get_expected_response() self._assert_current_settings(expected_response) - expected_response["division_scheme"] = "none" - self._assert_patched_settings({"division_scheme": "none"}, expected_response) + expected_response['division_scheme'] = 'none' + self._assert_patched_settings({'division_scheme': 'none'}, expected_response) def test_update_reported_content_email_notifications(self): """Test whether the 'reported_content_email_notifications' setting is updated.""" config_course_cohorts(self.course, is_cohorted=True) - config_course_discussions( - self.course, reported_content_email_notifications=True - ) + config_course_discussions(self.course, reported_content_email_notifications=True) expected_response = self._get_expected_response() - expected_response["reported_content_email_notifications"] = True + expected_response['reported_content_email_notifications'] = True self._login_as_staff() self._assert_current_settings(expected_response) @@ -1880,15 +1686,12 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe """ Test the course discussion roles management endpoint. """ - - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) @@ -1899,27 +1702,26 @@ def setUp(self): start=datetime.now(UTC), ) self.password = self.TEST_PASSWORD - self.user = UserFactory(username="staff", password=self.password, is_staff=True) - course_key = CourseKey.from_string("course-v1:x+y+z") + self.user = UserFactory(username='staff', password=self.password, is_staff=True) + course_key = CourseKey.from_string('course-v1:x+y+z') seed_permissions_roles(course_key) - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def path(self, course_id=None, role=None): """Return the URL path to the endpoint based on the provided arguments.""" course_id = str(self.course.id) if course_id is None else course_id - role = "Moderator" if role is None else role + role = 'Moderator' if role is None else role return reverse( - "discussion_course_roles", kwargs={"course_id": course_id, "rolename": role} + 'discussion_course_roles', + kwargs={'course_id': course_id, 'rolename': role} ) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication.""" - access_token = AccessTokenFactory.create( - user=user, application=ApplicationFactory() - ).token - headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } return headers def _login_as_staff(self): @@ -1944,11 +1746,9 @@ def _add_users_to_role(self, users, rolename): def post(self, role, user_id, action): """Make a POST request to the endpoint using the provided parameters.""" self._login_as_staff() - return self.client.post( - self.path(role=role), {"user_id": user_id, "action": action} - ) + return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action}) - @ddt.data("get", "post") + @ddt.data('get', 'post') def test_authentication_required(self, method): """Test and verify that authentication is required for this endpoint.""" self.client.logout() @@ -1961,31 +1761,29 @@ def test_oauth(self): self.client.logout() response = self.client.get(self.path(), **oauth_headers) assert response.status_code == 200 - body = {"user_id": "staff", "action": "allow"} - response = self.client.post(self.path(), body, format="json", **oauth_headers) + body = {'user_id': 'staff', 'action': 'allow'} + response = self.client.post(self.path(), body, format='json', **oauth_headers) assert response.status_code == 200 @ddt.data( - {"username": "u1", "is_staff": False, "expected_status": 403}, - {"username": "u2", "is_staff": True, "expected_status": 200}, + {'username': 'u1', 'is_staff': False, 'expected_status': 403}, + {'username': 'u2', 'is_staff': True, 'expected_status': 200}, ) @ddt.unpack def test_staff_permission_required(self, username, is_staff, expected_status): """Test and verify that only users with staff permission can access this endpoint.""" - UserFactory(username=username, password="edx", is_staff=is_staff) - self.client.login(username=username, password="edx") + UserFactory(username=username, password='edx', is_staff=is_staff) + self.client.login(username=username, password='edx') response = self.client.get(self.path()) assert response.status_code == expected_status - response = self.client.post( - self.path(), {"user_id": username, "action": "allow"}, format="json" - ) + response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json') assert response.status_code == expected_status def test_non_existent_course_id(self): """Test the response when the endpoint URL contains a non-existent course id.""" self._login_as_staff() - path = self.path(course_id="course-v1:a+b+c") + path = self.path(course_id='course-v1:a+b+c') response = self.client.get(path) assert response.status_code == 404 @@ -1996,7 +1794,7 @@ def test_non_existent_course_id(self): def test_non_existent_course_role(self): """Test the response when the endpoint URL contains a non-existent role.""" self._login_as_staff() - path = self.path(role="A") + path = self.path(role='A') response = self.client.get(path) assert response.status_code == 400 @@ -2005,10 +1803,10 @@ def test_non_existent_course_role(self): assert response.status_code == 400 @ddt.data( - {"role": "Moderator", "count": 0}, - {"role": "Moderator", "count": 1}, - {"role": "Group Moderator", "count": 2}, - {"role": "Community TA", "count": 3}, + {'role': 'Moderator', 'count': 0}, + {'role': 'Moderator', 'count': 1}, + {'role': 'Group Moderator', 'count': 2}, + {'role': 'Community TA', 'count': 3}, ) @ddt.unpack def test_get_role_members(self, role, count): @@ -2022,14 +1820,14 @@ def test_get_role_members(self, role, count): assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) - assert content["course_id"] == "course-v1:x+y+z" - assert len(content["results"]) == count - expected_fields = ("username", "email", "first_name", "last_name", "group_name") - for item in content["results"]: + content = json.loads(response.content.decode('utf-8')) + assert content['course_id'] == 'course-v1:x+y+z' + assert len(content['results']) == count + expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name') + for item in content['results']: for expected_field in expected_fields: assert expected_field in item - assert content["division_scheme"] == "cohort" + assert content['division_scheme'] == 'cohort' def test_post_missing_body(self): """Test the response with a POST request without a body.""" @@ -2038,9 +1836,9 @@ def test_post_missing_body(self): assert response.status_code == 400 @ddt.data( - {"a": 1}, - {"user_id": "xyz", "action": "allow"}, - {"user_id": "staff", "action": 123}, + {'a': 1}, + {'user_id': 'xyz', 'action': 'allow'}, + {'user_id': 'staff', 'action': 123}, ) def test_missing_or_invalid_parameters(self, body): """ @@ -2051,100 +1849,82 @@ def test_missing_or_invalid_parameters(self, body): response = self.client.post(self.path(), body) assert response.status_code == 400 - response = self.client.post(self.path(), body, format="json") + response = self.client.post(self.path(), body, format='json') assert response.status_code == 400 @ddt.data( - {"action": "allow", "user_in_role": False}, - {"action": "allow", "user_in_role": True}, - {"action": "revoke", "user_in_role": False}, - {"action": "revoke", "user_in_role": True}, + {'action': 'allow', 'user_in_role': False}, + {'action': 'allow', 'user_in_role': True}, + {'action': 'revoke', 'user_in_role': False}, + {'action': 'revoke', 'user_in_role': True} ) @ddt.unpack def test_post_update_user_role(self, action, user_in_role): """Test the response when updating the user's role""" users = self._create_and_enroll_users(count=1) user = users[0] - role = "Moderator" + role = 'Moderator' if user_in_role: self._add_users_to_role(users, role) response = self.post(role, user.username, action) assert response.status_code == 200 - content = json.loads(response.content.decode("utf-8")) - assertion = self.assertTrue if action == "allow" else self.assertFalse - assertion(any(user.username in x["username"] for x in content["results"])) + content = json.loads(response.content.decode('utf-8')) + assertion = self.assertTrue if action == 'allow' else self.assertFalse + assertion(any(user.username in x['username'] for x in content['results'])) @ddt.ddt @httpretty.activate @override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True) -class CourseActivityStatsTest( - ForumsEnableMixin, - UrlResetMixin, - CommentsServiceMockMixin, - APITestCase, - SharedModuleStoreTestCase, -): +class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase, + SharedModuleStoreTestCase): """ Tests for the course stats endpoint """ - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self) -> None: super().setUp() patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", - return_value=False, + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) patcher.start() self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) - self.user = UserFactory(username="user") - self.moderator = UserFactory(username="moderator") + self.user = UserFactory(username='user') + self.moderator = UserFactory(username='moderator') moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id) moderator_role.users.add(self.moderator) self.stats = [ { - "threads": random.randint(0, 10), - "replies": random.randint(0, 30), - "responses": random.randint(0, 100), - "deleted_threads": 0, - "deleted_replies": 0, - "deleted_responses": 0, "active_flags": random.randint(0, 3), "inactive_flags": random.randint(0, 2), - "username": f"user-{idx}", + "replies": random.randint(0, 30), + "responses": random.randint(0, 100), + "threads": random.randint(0, 10), + "username": f"user-{idx}" } for idx in range(10) ] for stat in self.stats: user = UserFactory.create( - username=stat["username"], + username=stat['username'], email=f"{stat['username']}@example.com", - password=self.TEST_PASSWORD, + password=self.TEST_PASSWORD ) - CourseEnrollment.enroll(user, self.course.id, mode="audit") + CourseEnrollment.enroll(user, self.course.id, mode='audit') - CourseEnrollment.enroll(self.moderator, self.course.id, mode="audit") - self.stats_without_flags = [ - {**stat, "active_flags": None, "inactive_flags": None} - for stat in self.stats - ] + CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit') + self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats] self.register_course_stats_response(self.course_key, self.stats, 1, 3) - self.url = reverse( - "discussion_course_activity_stats", - kwargs={"course_key_string": self.course_key}, - ) + self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key}) - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_regular_user(self): """ Tests that for a regular user stats are returned without flag counts @@ -2154,9 +1934,7 @@ def test_regular_user(self): data = response.json() assert data["results"] == self.stats_without_flags - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_moderator_user(self): """ Tests that for a moderator user stats are returned with flag counts @@ -2176,9 +1954,7 @@ def test_moderator_user(self): ("user", "recency", "recency"), ) @ddt.unpack - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_sorting(self, username, ordering_requested, ordering_performed): """ Test valid sorting options and defaults @@ -2188,22 +1964,15 @@ def test_sorting(self, username, ordering_requested, ordering_performed): if ordering_requested: params = {"order_by": ordering_requested} self.client.get(self.url, params) - assert ( - urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path - == f"/api/v1/users/{self.course_key}/stats" - ) + assert urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path == f"/api/v1/users/{self.course_key}/stats" assert parse_qs( - urlparse( - httpretty.last_request().path - ).query # lint-amnesty, pylint: disable=no-member + urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member ).get("sort_key", None) == [ordering_performed] @ddt.data("flagged", "xyz") - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def test_sorting_error_regular_user(self, order_by): """ Test for invalid sorting options for regular users. @@ -2213,60 +1982,47 @@ def test_sorting_error_regular_user(self, order_by): assert "order_by" in response.json()["field_errors"] @ddt.data( - ( - "user", - "user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9", - ), - ("moderator", "moderator"), + ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'), + ('moderator', 'moderator'), ) @ddt.unpack - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) - def test_with_username_param( - self, username_search_string, comma_separated_usernames - ): + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) + def test_with_username_param(self, username_search_string, comma_separated_usernames): """ Test for endpoint with username param. """ - params = {"username": username_search_string} + params = {'username': username_search_string} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) self.client.get(self.url, params) - assert ( - urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path - == f"/api/v1/users/{self.course_key}/stats" - ) + assert urlparse( + httpretty.last_request().path # lint-amnesty, pylint: disable=no-member + ).path == f'/api/v1/users/{self.course_key}/stats' assert parse_qs( - urlparse( - httpretty.last_request().path - ).query # lint-amnesty, pylint: disable=no-member - ).get("usernames", [None]) == [comma_separated_usernames] + urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member + ).get('usernames', [None]) == [comma_separated_usernames] - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} - ) + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) def test_with_username_param_with_no_matches(self): """ Test for endpoint with username param with no matches. """ - params = {"username": "unknown"} + params = {'username': 'unknown'} self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, params) data = response.json() - self.assertFalse(data["results"]) - assert data["pagination"]["count"] == 0 + self.assertFalse(data['results']) + assert data['pagination']['count'] == 0 - @ddt.data("user-0", "USER-1", "User-2", "UsEr-3") - @mock.patch.dict( - "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + @ddt.data( + 'user-0', + 'USER-1', + 'User-2', + 'UsEr-3' ) + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True}) def test_with_username_param_case(self, username_search_string): """ Test user search function is case-insensitive. """ - response = get_usernames_from_search_string( - self.course_key, username_search_string, 1, 1 - ) + response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1) assert response == (username_search_string.lower(), 1, 1) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 39e48dd41ff9..431304a9a2b5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -14,6 +14,8 @@ from unittest import mock import ddt +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread import httpretty from django.urls import reverse from pytz import UTC @@ -21,39 +23,30 @@ from rest_framework.parsers import JSONParser from rest_framework.test import APIClient +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal -from forum.backends.mongodb.comments import Comment -from forum.backends.mongodb.threads import CommentThread -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, ) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.tests.utils import ( ForumMockUtilsMixin, ProfileImageTestMixin, make_paginated_api_response, ) -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_STUDENT, - assign_role, + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + assign_role ) -from openedx.core.djangoapps.user_api.accounts.image_helpers import ( - get_profile_image_storage, -) -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): @@ -394,10 +387,6 @@ def expected_response_data(self, overrides=None): "image_url_small": "http://testserver/static/default_30.png", }, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -523,17 +512,15 @@ def test_course_id_missing(self): self.assert_response_correct( response, 400, - { - "field_errors": { - "course_id": {"developer_message": "This field is required."} - } - }, + {"field_errors": {"course_id": {"developer_message": "This field is required."}}} ) def test_404(self): response = self.client.get(self.url, {"course_id": "non/existent/course"}) self.assert_response_correct( - response, 404, {"developer_message": "Course not found."} + response, + 404, + {"developer_message": "Course not found."} ) def test_basic(self): @@ -884,9 +871,7 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() - self.url = reverse( - "bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)} - ) + self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) self.user2 = UserFactory.create(password=self.password) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) @@ -902,19 +887,13 @@ def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): thread_collection = mock.MagicMock() thread_collection.count_documents.return_value = thread_count patch_thread = mock.patch.object( - CommentThread, - "_collection", - new_callable=mock.PropertyMock, - return_value=thread_collection, + CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection ) comment_collection = mock.MagicMock() comment_collection.count_documents.return_value = comment_count patch_comment = mock.patch.object( - Comment, - "_collection", - new_callable=mock.PropertyMock, - return_value=comment_collection, + Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection ) thread_mock = patch_thread.start() @@ -929,9 +908,7 @@ def test_bulk_delete_denied_for_discussion_roles(self, role): """ Test bulk delete user posts denied with discussion roles. """ - thread_mock, comment_mock = self.mock_comment_and_thread_count( - comment_count=1, thread_count=1 - ) + thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) assign_role(self.course.id, self.user, role) response = self.client.post( f"{self.url}?username={self.user2.username}", @@ -955,9 +932,7 @@ def test_bulk_delete_allowed_for_discussion_roles(self, role): assert response.status_code == status.HTTP_202_ACCEPTED assert response.json() == {"comment_count": 1, "thread_count": 1} - @mock.patch( - "lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async" - ) + @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') @ddt.data(True, False) def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): """ diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 990e68a30af9..8c1615690ad5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -2,6 +2,7 @@ Discussion API test utilities """ + import hashlib import json import re @@ -13,18 +14,11 @@ from PIL import Image from pytz import UTC -from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( - MockForumApiMixin, -) -from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( - CommentClientRequestError, -) +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file -from openedx.core.djangoapps.user_api.accounts.image_helpers import ( - get_profile_image_names, - set_has_profile_image, -) +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image def _get_thread_callback(thread_data): @@ -32,7 +26,6 @@ def _get_thread_callback(thread_data): Get a callback function that will return POST/PUT data overridden by response_overrides. """ - def callback(request, _uri, headers): """ Simulate the thread creation or update endpoint by returning the provided @@ -49,7 +42,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": thread_data.get("username"), + "author": thread_data.get('username'), "reason_code": val, }, ] @@ -75,13 +68,11 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": thread_data.get("username"), - "reason_code": val, - } - ] + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + }] else: response_data[key] = val @@ -96,7 +87,6 @@ def _get_comment_callback(comment_data, thread_id, parent_id): plus necessary dummy data, overridden by the content of the POST/PUT request. """ - def callback(request, _uri, headers): """ Simulate the comment creation or update endpoint as described above. @@ -115,7 +105,7 @@ def callback(request, _uri, headers): response_data["edit_history"] = [ { "original_body": original_data["body"], - "author": comment_data.get("username"), + "author": comment_data.get('username'), "reason_code": val, }, ] @@ -145,13 +135,11 @@ def callback(*args, **kwargs): if key in ["anonymous", "anonymous_to_peers", "endorsed"]: response_data[key] = val is True or val == "True" elif key == "edit_reason_code": - response_data["edit_history"] = [ - { - "original_body": original_data["body"], - "author": comment_data.get("username"), - "reason_code": val, - } - ] + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }] else: response_data[key] = val @@ -164,11 +152,9 @@ def make_user_callbacks(user_map): """ Returns a callable that mimics user creation. """ - def callback(*args, **kwargs): - user_id = args[0] if args else kwargs.get("user_id") + user_id = args[0] if args else kwargs.get('user_id') return user_map[str(user_id)] - return callback @@ -177,58 +163,54 @@ class CommentsServiceMockMixin: def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads", - body=json.dumps( - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }), + status=200 ) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/commentables/{course_id}/counts", body=json.dumps(thread_counts), - status=200, + status=200 ) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/search/threads", - body=json.dumps( - { - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }), + status=200 ) def register_post_thread_response(self, thread_data): """Register a mock response for POST on the CS commentable endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, re.compile(r"http://localhost:4567/api/v1/(\w+)/threads"), - body=_get_thread_callback(thread_data), + body=_get_thread_callback(thread_data) ) def register_put_thread_response(self, thread_data): @@ -236,51 +218,49 @@ def register_put_thread_response(self, thread_data): Register a mock response for PUT on the CS endpoint for the given thread_id. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/threads/{}".format(thread_data["id"]), - body=_get_thread_callback(thread_data), + body=_get_thread_callback(thread_data) ) def register_get_thread_error_response(self, thread_id, status_code): """Register a mock error response for GET on the CS thread endpoint.""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}", body="", - status=status_code, + status=status_code ) def register_get_thread_response(self, thread): """ Register a mock response for GET on the CS thread instance endpoint. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/threads/{id}".format(id=thread["id"]), body=json.dumps(thread), - status=200, + status=200 ) def register_get_comments_response(self, comments, page, num_pages): """Register a mock response for GET on the CS comments list endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments", - body=json.dumps( - { - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - } - ), - status=200, + body=json.dumps({ + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }), + status=200 ) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): @@ -294,11 +274,11 @@ def register_post_comment_response(self, comment_data, thread_id, parent_id=None else: url = f"http://localhost:4567/api/v1/threads/{thread_id}/comments" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, url, - body=_get_comment_callback(comment_data, thread_id, parent_id), + body=_get_comment_callback(comment_data, thread_id, parent_id) ) def register_put_comment_response(self, comment_data): @@ -308,11 +288,11 @@ def register_put_comment_response(self, comment_data): """ thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.PUT, "http://localhost:4567/api/v1/comments/{}".format(comment_data["id"]), - body=_get_comment_callback(comment_data, thread_id, parent_id), + body=_get_comment_callback(comment_data, thread_id, parent_id) ) def register_get_comment_error_response(self, comment_id, status_code): @@ -320,12 +300,12 @@ def register_get_comment_error_response(self, comment_id, status_code): Register a mock error response for GET on the CS comment instance endpoint. """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/comments/{comment_id}", body="", - status=status_code, + status=status_code ) def register_get_comment_response(self, response_overrides): @@ -333,83 +313,75 @@ def register_get_comment_response(self, response_overrides): Register a mock response for GET on the CS comment instance endpoint. """ comment = make_minimal_cs_comment(response_overrides) - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, "http://localhost:4567/api/v1/comments/{id}".format(id=comment["id"]), body=json.dumps(comment), - status=200, + status=200 ) - def register_get_user_response( - self, user, subscribed_thread_ids=None, upvoted_ids=None - ): + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}", - body=json.dumps( - { - "id": str(user.id), - "subscribed_thread_ids": subscribed_thread_ids or [], - "upvoted_ids": upvoted_ids or [], - } - ), - status=200, + body=json.dumps({ + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + }), + status=200 ) def register_get_user_retire_response(self, user, status=200, body=""): """Register a mock response for GET on the CS user retirement endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/retire", body=body, - status=status, + status=status ) def register_get_username_replacement_response(self, user, status=200, body=""): - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/replace_username", body=body, - status=status, + status=status ) def register_subscribed_threads_response(self, user, threads, page, num_pages): """Register a mock response for GET on the CS user instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user.id}/subscribed_threads", - body=json.dumps( - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - } - ), - status=200, + body=json.dumps({ + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }), + status=200 ) def register_course_stats_response(self, course_key, stats, page, num_pages): """Register a mock response for GET on the CS user course stats instance endpoint""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{course_key}/stats", - body=json.dumps( - { - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - } - ), - status=200, + body=json.dumps({ + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }), + status=200 ) def register_subscription_response(self, user): @@ -417,13 +389,13 @@ def register_subscription_response(self, user): Register a mock response for POST and DELETE on the CS user subscription endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.POST, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/users/{user.id}/subscriptions", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_thread_votes_response(self, thread_id): @@ -431,13 +403,13 @@ def register_thread_votes_response(self, thread_id): Register a mock response for PUT and DELETE on the CS thread votes endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/threads/{thread_id}/votes", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_comment_votes_response(self, comment_id): @@ -445,39 +417,41 @@ def register_comment_votes_response(self, comment_id): Register a mock response for PUT and DELETE on the CS comment votes endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for method in [httpretty.PUT, httpretty.DELETE]: httpretty.register_uri( method, f"http://localhost:4567/api/v1/comments/{comment_id}/votes", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_flag_response(self, content_type, content_id): """Register a mock response for PUT on the CS flag endpoints""" - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' for path in ["abuse_flag", "abuse_unflag"]: httpretty.register_uri( "PUT", "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( - content_type=content_type, content_id=content_id, path=path + content_type=content_type, + content_id=content_id, + path=path ), body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_read_response(self, user, content_type, content_id): """ Register a mock response for POST on the CS 'read' endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.POST, f"http://localhost:4567/api/v1/users/{user.id}/read", - params={"source_type": content_type, "source_id": content_id}, + params={'source_type': content_type, 'source_id': content_id}, body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_thread_flag_response(self, thread_id): @@ -492,48 +466,48 @@ def register_delete_thread_response(self, thread_id): """ Register a mock response for DELETE on the CS thread instance endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/threads/{thread_id}", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_delete_comment_response(self, comment_id): """ Register a mock response for DELETE on the CS comment instance endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.DELETE, f"http://localhost:4567/api/v1/comments/{comment_id}", body=json.dumps({}), # body is unused - status=200, + status=200 ) def register_user_active_threads(self, user_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/users/{user_id}/active_threads", body=json.dumps(response), - status=200, + status=200 ) def register_get_subscriptions(self, thread_id, response): """ Register a mock response for GET on the CS comment active threads endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' httpretty.register_uri( httpretty.GET, f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", body=json.dumps(response), - status=200, + status=200 ) def assert_query_params_equal(self, httpretty_request, expected_params): @@ -557,7 +531,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json", + content_type="application/merge-patch+json" ) def expected_thread_data(self, overrides=None): @@ -615,10 +589,6 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -629,153 +599,137 @@ class ForumMockUtilsMixin(MockForumApiMixin): def register_get_threads_response(self, threads, page, num_pages): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value( - "get_user_threads", - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('get_user_threads', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) def register_get_course_commentable_counts_response(self, course_id, thread_counts): """Register a mock response for GET on the CS thread list endpoint""" - self.set_mock_return_value("get_commentables_stats", thread_counts) + self.set_mock_return_value('get_commentables_stats', thread_counts) def register_get_threads_search_response(self, threads, rewrite, num_pages=1): """Register a mock response for GET on the CS thread search endpoint""" - self.set_mock_return_value( - "search_threads", - { - "collection": threads, - "page": 1, - "num_pages": num_pages, - "corrected_text": rewrite, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('search_threads', { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }) def register_post_thread_response(self, thread_data): - self.set_mock_side_effect("create_thread", make_thread_callback(thread_data)) + self.set_mock_side_effect('create_thread', make_thread_callback(thread_data)) def register_put_thread_response(self, thread_data): - self.set_mock_side_effect("update_thread", make_thread_callback(thread_data)) + self.set_mock_side_effect('update_thread', make_thread_callback(thread_data)) def register_get_thread_error_response(self, thread_id, status_code): self.set_mock_side_effect( - "get_thread", - CommentClientRequestError(f"Thread does not exist with Id: {thread_id}"), + 'get_thread', + CommentClientRequestError(f"Thread does not exist with Id: {thread_id}") ) def register_get_thread_response(self, thread): - self.set_mock_return_value("get_thread", thread) + self.set_mock_return_value('get_thread', thread) def register_get_comments_response(self, comments, page, num_pages): - self.set_mock_return_value( - "get_parent_comment", - { - "collection": comments, - "page": page, - "num_pages": num_pages, - "comment_count": len(comments), - }, - ) + self.set_mock_return_value('get_parent_comment', { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }) def register_post_comment_response(self, comment_data, thread_id, parent_id=None): self.set_mock_side_effect( - "create_child_comment" if parent_id else "create_parent_comment", - make_comment_callback(comment_data, thread_id, parent_id), + 'create_child_comment' if parent_id else 'create_parent_comment', + make_comment_callback(comment_data, thread_id, parent_id) ) def register_put_comment_response(self, comment_data): thread_id = comment_data["thread_id"] parent_id = comment_data.get("parent_id") self.set_mock_side_effect( - "update_comment", make_comment_callback(comment_data, thread_id, parent_id) + 'update_comment', + make_comment_callback(comment_data, thread_id, parent_id) ) def register_get_comment_error_response(self, comment_id, status_code): self.set_mock_side_effect( - "get_parent_comment", - CommentClientRequestError(f"Comment does not exist with Id: {comment_id}"), + 'get_parent_comment', + CommentClientRequestError(f"Comment does not exist with Id: {comment_id}") ) def register_get_comment_response(self, response_overrides): comment = make_minimal_cs_comment(response_overrides) - self.set_mock_return_value("get_parent_comment", comment) + self.set_mock_return_value('get_parent_comment', comment) - def register_get_user_response( - self, user, subscribed_thread_ids=None, upvoted_ids=None - ): + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): """Register a mock response for GET on the CS user endpoint""" self.users_map[str(user.id)] = { "id": str(user.id), "subscribed_thread_ids": subscribed_thread_ids or [], "upvoted_ids": upvoted_ids or [], } - self.set_mock_side_effect("get_user", make_user_callbacks(self.users_map)) + self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) def register_get_user_retire_response(self, user, body=""): - self.set_mock_return_value("retire_user", body) + self.set_mock_return_value('retire_user', body) def register_get_username_replacement_response(self, user, status=200, body=""): - self.set_mock_return_value("update_username", body) + self.set_mock_return_value('update_username', body) def register_subscribed_threads_response(self, user, threads, page, num_pages): - self.set_mock_return_value( - "get_user_subscriptions", - { - "collection": threads, - "page": page, - "num_pages": num_pages, - "thread_count": len(threads), - }, - ) + self.set_mock_return_value('get_user_subscriptions', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) def register_course_stats_response(self, course_key, stats, page, num_pages): - self.set_mock_return_value( - "get_user_course_stats", - { - "user_stats": stats, - "page": page, - "num_pages": num_pages, - "count": len(stats), - }, - ) + self.set_mock_return_value('get_user_course_stats', { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }) def register_subscription_response(self, user): - self.set_mock_return_value("create_subscription", {}) - self.set_mock_return_value("delete_subscription", {}) + self.set_mock_return_value('create_subscription', {}) + self.set_mock_return_value('delete_subscription', {}) def register_thread_votes_response(self, thread_id): - self.set_mock_return_value("update_thread_votes", {}) - self.set_mock_return_value("delete_thread_vote", {}) + self.set_mock_return_value('update_thread_votes', {}) + self.set_mock_return_value('delete_thread_vote', {}) def register_comment_votes_response(self, comment_id): - self.set_mock_return_value("update_comment_votes", {}) - self.set_mock_return_value("delete_comment_vote", {}) + self.set_mock_return_value('update_comment_votes', {}) + self.set_mock_return_value('delete_comment_vote', {}) def register_flag_response(self, content_type, content_id): - if content_type == "thread": - self.set_mock_return_value("update_thread_flag", {}) - elif content_type == "comment": - self.set_mock_return_value("update_comment_flag", {}) + if content_type == 'thread': + self.set_mock_return_value('update_thread_flag', {}) + elif content_type == 'comment': + self.set_mock_return_value('update_comment_flag', {}) def register_read_response(self, user, content_type, content_id): - self.set_mock_return_value("mark_thread_as_read", {}) + self.set_mock_return_value('mark_thread_as_read', {}) def register_delete_thread_response(self, thread_id): - self.set_mock_return_value("delete_thread", {}) + self.set_mock_return_value('delete_thread', {}) def register_delete_comment_response(self, comment_id): - self.set_mock_return_value("delete_comment", {}) + self.set_mock_return_value('delete_comment', {}) def register_user_active_threads(self, user_id, response): - self.set_mock_return_value("get_user_active_threads", response) + self.set_mock_return_value('get_user_active_threads', response) def register_get_subscriptions(self, thread_id, response): - self.set_mock_return_value("get_thread_subscriptions", response) + self.set_mock_return_value('get_thread_subscriptions', response) def register_thread_flag_response(self, thread_id): """Register a mock response for PUT on the CS thread flag endpoints""" @@ -806,7 +760,7 @@ def request_patch(self, request_data): return self.client.patch( self.url, json.dumps(request_data), - content_type="application/merge-patch+json", + content_type="application/merge-patch+json" ) def expected_thread_data(self, overrides=None): @@ -864,10 +818,6 @@ def expected_thread_data(self, overrides=None): "close_reason": None, "close_reason_code": None, "learner_status": "new", - "is_deleted": None, - "deleted_at": None, - "deleted_by": None, - "deleted_by_label": None, } response_data.update(overrides or {}) return response_data @@ -940,9 +890,7 @@ def make_minimal_cs_comment(overrides=None): return ret -def make_paginated_api_response( - results=None, count=0, num_pages=0, next_link=None, previous_link=None -): +def make_paginated_api_response(results=None, count=0, num_pages=0, next_link=None, previous_link=None): """ Generates the response dictionary of paginated APIs with passed data """ @@ -953,7 +901,7 @@ def make_paginated_api_response( "count": count, "num_pages": num_pages, }, - "results": results or [], + "results": results or [] } @@ -971,9 +919,7 @@ def create_profile_image(self, user, storage): with make_image_file() as image_file: create_profile_images(image_file, get_profile_image_names(user.username)) self.check_images(user, storage) - set_has_profile_image( - user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT - ) + set_has_profile_image(user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT) def check_images(self, user, storage, exist=True): """ @@ -987,7 +933,7 @@ def check_images(self, user, storage, exist=True): assert storage.exists(name) with closing(Image.open(storage.path(name))) as img: assert img.size == (size, size) - assert img.format == "JPEG" + assert img.format == 'JPEG' else: assert not storage.exists(name) @@ -995,18 +941,18 @@ def get_expected_user_profile(self, username): """ Returns the expected user profile data for a given username """ - url = "http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}".format( - filename=hashlib.md5(b"secret" + username.encode("utf-8")).hexdigest(), - timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"), + url = 'http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}'.format( + filename=hashlib.md5(b'secret' + username.encode('utf-8')).hexdigest(), + timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s") ) return { - "profile": { - "image": { - "has_image": True, - "image_url_full": url.format(size=500), - "image_url_large": url.format(size=120), - "image_url_medium": url.format(size=50), - "image_url_small": url.format(size=30), + 'profile': { + 'image': { + 'has_image': True, + 'image_url_full': url.format(size=500), + 'image_url_large': url.format(size=120), + 'image_url_medium': url.format(size=50), + 'image_url_small': url.format(size=30), } } } @@ -1016,14 +962,14 @@ def parsed_body(request): """Returns a parsed dictionary version of a request body""" # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.body.decode("utf8")) + return parse_qs(request.body.decode('utf8')) def querystring(request): """Returns a parsed dictionary version of a query string""" # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 - return parse_qs(request.path.split("?", 1)[-1]) + return parse_qs(request.path.split('?', 1)[-1]) class ThreadMock(object): @@ -1031,9 +977,7 @@ class ThreadMock(object): A mock thread object """ - def __init__( - self, thread_id, creator, title, parent_id=None, body="", commentable_id=None - ): + def __init__(self, thread_id, creator, title, parent_id=None, body='', commentable_id=None): self.id = thread_id self.user_id = str(creator.id) self.username = creator.username diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index 9753774f075c..f102dc41f249 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -9,7 +9,6 @@ from lms.djangoapps.discussion.rest_api.views import ( BulkDeleteUserPosts, - BulkRestoreUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -19,10 +18,8 @@ CourseTopicsViewV3, CourseView, CourseViewV2, - DeletedContentView, LearnerThreadView, ReplaceUsernamesView, - RestoreContent, RetireUserView, ThreadViewSet, UploadFileView, @@ -34,22 +31,26 @@ 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( - rf"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", + fr"^v1/courses/{settings.COURSE_KEY_PATTERN}/activity_stats", CourseActivityStatsView.as_view(), name="discussion_course_activity_stats", ), re_path( - rf"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", + fr"^v1/courses/{settings.COURSE_ID_PATTERN}/upload$", UploadFileView.as_view(), name="upload_file", ), @@ -61,55 +62,36 @@ name="discussion_course_roles", ), re_path( - rf"^v1/courses/{settings.COURSE_ID_PATTERN}", + fr"^v1/courses/{settings.COURSE_ID_PATTERN}", CourseView.as_view(), - name="discussion_course", + name="discussion_course" ), re_path( - rf"^v2/courses/{settings.COURSE_ID_PATTERN}", + fr"^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( - 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}", + fr"^v1/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsView.as_view(), - name="course_topics", + name="course_topics" ), re_path( - rf"^v2/course_topics/{settings.COURSE_ID_PATTERN}", + fr"^v2/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV2.as_view(), - name="course_topics_v2", + name="course_topics_v2" ), re_path( - rf"^v3/course_topics/{settings.COURSE_ID_PATTERN}", + fr"^v3/course_topics/{settings.COURSE_ID_PATTERN}", CourseTopicsViewV3.as_view(), - name="course_topics_v3", + name="course_topics_v3" ), re_path( - rf"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", BulkDeleteUserPosts.as_view(), - 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", + name="bulk_delete_user_posts" ), - 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 3556f78562fe..ba9818124e08 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,19 +1,17 @@ """ 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 @@ -23,49 +21,31 @@ 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, - restore_course_post_for_user, -) +from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST +from lms.djangoapps.discussion.django_comment_client import settings as cc_settings +from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role -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.django_comment_common.models import ( - CourseDiscussionSettings, - Role, -) -from openedx.core.djangoapps.user_api.accounts.permissions import ( - CanReplaceUsername, - CanRetireUser, -) +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, @@ -77,10 +57,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, @@ -108,10 +88,10 @@ from .utils import ( create_blocks_params, create_topics_v3_structure, - get_course_id_from_thread_id, is_captcha_enabled, - is_only_student, verify_recaptcha_token, + get_course_id_from_thread_id, + is_only_student, ) log = logging.getLogger(__name__) @@ -127,16 +107,14 @@ 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): """ @@ -148,9 +126,7 @@ 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)) @@ -162,16 +138,14 @@ 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): """ @@ -182,9 +156,7 @@ 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)) @@ -249,14 +221,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, ) @@ -296,17 +268,19 @@ 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, @@ -314,9 +288,7 @@ 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) @@ -332,17 +304,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, @@ -355,7 +327,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): """ @@ -376,7 +348,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) @@ -444,17 +416,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) @@ -655,12 +627,8 @@ 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): """ @@ -673,10 +641,7 @@ 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( @@ -695,15 +660,14 @@ 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): @@ -717,28 +681,21 @@ 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): @@ -805,27 +762,24 @@ 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) - show_deleted = request.GET.get("show_deleted", "false").lower() == "true" + order_by = order_by_mapping.get(order_by, 'activity') + post_status = request.GET.get('status', None) 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 @@ -838,17 +792,14 @@ 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) @@ -1017,12 +968,8 @@ 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): """ @@ -1063,8 +1010,7 @@ 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["show_deleted"], + form.cleaned_data["merge_question_type_responses"] ) def list_by_user(self, request): @@ -1111,28 +1057,21 @@ 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): @@ -1208,11 +1147,8 @@ 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 @@ -1253,12 +1189,10 @@ 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 @@ -1313,9 +1247,7 @@ 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: @@ -1325,8 +1257,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): @@ -1372,7 +1304,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: @@ -1432,16 +1364,12 @@ 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): @@ -1457,14 +1385,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, ) @@ -1483,15 +1411,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, @@ -1560,7 +1488,6 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): * division_scheme: The division scheme used by the course. """ - authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -1581,13 +1508,11 @@ 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) @@ -1601,25 +1526,23 @@ 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) @@ -1643,9 +1566,7 @@ class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): """ authentication_classes = ( - JwtAuthentication, - BearerAuthentication, - SessionAuthentication, + JwtAuthentication, BearerAuthentication, SessionAuthentication, ) permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) @@ -1666,26 +1587,23 @@ 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, @@ -1695,256 +1613,5 @@ def post(self, request, course_id): ) return Response( {"comment_count": comment_count, "thread_count": thread_count}, - 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, + 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 dae2088594ae..8905679a45db 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,17 +4,13 @@ from bs4 import BeautifulSoup -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 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__) @@ -22,56 +18,26 @@ 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", - "is_deleted", - "deleted_at", - "deleted_by", + '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', ] 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) @@ -80,7 +46,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 @@ -90,22 +56,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) @@ -118,7 +84,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) @@ -136,7 +102,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 @@ -148,15 +114,12 @@ def get_user_comment_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, - "_type": "Comment", + "_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, deleted_by=None): + def delete_user_comments(cls, user_id, course_ids): """ Deletes comments and responses of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -165,66 +128,21 @@ def delete_user_comments(cls, user_id, course_ids, deleted_by=None): 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: - # 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 - ) + forum_api.delete_comment(comment_id, course_id=course_id) 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 - ) - 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 ddfcc37cc524..4544a463ed80 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -4,28 +4,24 @@ 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_disabled_globally, - is_forum_v2_enabled, -) - -from .utils import CommentClientRequestError, extract, get_course_key, perform_request +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally 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): @@ -33,21 +29,18 @@ 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 as e: + except KeyError: if self.retrieved or self.id is None: - raise AttributeError(f"Field {name} does not exist") from e + raise AttributeError(f"Field {name} does not exist") # lint-amnesty, pylint: disable=raise-missing-from 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 @@ -83,9 +76,7 @@ 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) @@ -100,11 +91,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 @@ -123,11 +114,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): @@ -137,7 +128,8 @@ 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__ ) ) @@ -160,7 +152,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) @@ -168,25 +160,13 @@ def save(self, params=None): self._update_from_response(response) self.after_save(self) - def delete(self, course_id=None, deleted_by=None): + def delete(self, course_id=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( # pylint: disable=unexpected-keyword-arg - comment_id=self.attributes["id"], - course_id=str(course_key), - deleted_by=deleted_by, - ) - ) + response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) elif self.type == "thread": - response = ( - forum_api.delete_thread( # pylint: disable=unexpected-keyword-arg - thread_id=self.attributes["id"], - course_id=str(course_key), - deleted_by=deleted_by, - ) - ) + response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") self.retrieved = True @@ -196,7 +176,7 @@ def delete(self, course_id=None, deleted_by=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): @@ -207,21 +187,17 @@ 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: + 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 ValueError( f"Invalid action {action}. The supported action must be in {str(cls.DEFAULT_ACTIONS)}" ) - if action in cls.DEFAULT_ACTIONS_WITH_ID: + elif action in cls.DEFAULT_ACTIONS_WITH_ID: try: return cls.url_with_id(params) - 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 + 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 return cls.url_without_id() def handle_update(self, params=None): @@ -330,8 +306,8 @@ def handle_create(self, params=None): try: return handlers[self.type](course_key) - except KeyError as e: - raise CommentClientRequestError(f"Unsupported type: {self.type}") from e + except KeyError as exc: + raise CommentClientRequestError(f"Unsupported type: {self.type}") from exc def handle_create_comment(self, course_id): request_data = self.initializable_attributes() @@ -343,8 +319,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 754fe0065f00..34ccd7bf2ce6 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -5,104 +5,50 @@ 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, -) # 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 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 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", - "is_deleted", - "deleted_at", - "deleted_by", + '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', ] # 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): @@ -112,83 +58,82 @@ 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 query_params.get("show_deleted", False): - params["is_deleted"] = True + 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) + 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" @@ -201,9 +146,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) @@ -213,23 +158,21 @@ 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, @@ -238,7 +181,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) @@ -246,12 +189,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) @@ -260,7 +203,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) @@ -268,14 +211,18 @@ 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) @@ -288,15 +235,12 @@ def get_user_threads_count(cls, user_id, course_ids): query_params = { "course_id": {"$in": course_ids}, "author_id": str(user_id), - "is_deleted": {"$ne": True}, - "_type": "CommentThread", + "_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, deleted_by=None): + def _delete_thread(cls, thread_id, course_id=None): """ Deletes a thread """ @@ -313,53 +257,34 @@ def _delete_thread(cls, thread_id, course_id=None, deleted_by=None): ) from exc start_time = time.perf_counter() - # 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" - ) + backend.delete_comments_of_a_thread(thread_id) + 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.soft_delete_thread(thread_id, deleted_by) + result = backend.delete_thread(thread_id) 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, - 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, + thread["author_id"], thread["course_id"], threads=-1 ) 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, deleted_by=None): + def delete_user_threads(cls, user_id, course_ids): """ Deletes threads of user in the given course_ids. TODO: Add support for MySQL backend as well @@ -368,65 +293,21 @@ def delete_user_threads(cls, user_id, course_ids, deleted_by=None): 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, deleted_by=deleted_by - ) + cls._delete_thread(thread_id, course_id=course_id) 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 - ) - def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag"