From b31fe4d8b37bb4bbcc43adc17c1069b2f6f87f8f Mon Sep 17 00:00:00 2001 From: naincy128 Date: Tue, 13 Jan 2026 09:46:58 +0000 Subject: [PATCH] feat: implement mute/unmute feature --- forum/admin.py | 51 +- forum/ai_moderation.py | 6 +- forum/api/__init__.py | 17 +- forum/api/mutes.py | 222 +++++++++ forum/api/subscriptions.py | 2 +- forum/api/users.py | 2 +- forum/backend.py | 3 +- forum/backends/backend.py | 187 +++++++ forum/backends/mongodb/api.py | 338 ++++++++++++- forum/backends/mongodb/base_model.py | 30 ++ forum/backends/mongodb/contents.py | 2 +- forum/backends/mongodb/mutes.py | 462 ++++++++++++++++++ forum/backends/mysql/__init__.py | 2 +- forum/backends/mysql/api.py | 335 ++++++++++++- forum/backends/mysql/models.py | 223 +++++++++ forum/handlers.py | 9 +- .../commands/delete_unused_forum_indices.py | 2 +- .../commands/forum_create_mongodb_indexes.py | 2 +- .../forum_create_mute_mongodb_indexes.py | 297 +++++++++++ .../forum_delete_course_from_mongodb.py | 2 +- ...um_migrate_course_from_mongodb_to_mysql.py | 5 +- .../commands/initialize_forum_indices.py | 2 +- .../commands/rebuild_forum_indices.py | 2 +- .../commands/validate_forum_indices.py | 2 +- forum/migration_helpers.py | 6 +- .../0006_add_discussion_mute_models.py | 200 ++++++++ forum/serializers/mute.py | 274 +++++++++++ forum/views/commentables.py | 2 +- forum/views/comments.py | 6 +- forum/views/mutes.py | 291 +++++++++++ forum/views/threads.py | 2 +- forum/views/users.py | 2 +- test_utils/mock_es_backend.py | 3 +- tests/conftest.py | 4 +- tests/e2e/test_search_meilisearch.py | 2 +- .../test_commands/test_migration_commands.py | 3 +- tests/test_meilisearch.py | 3 +- tests/test_views/test_commentables.py | 3 +- tests/test_views/test_comments.py | 1 + tests/test_views/test_flags.py | 1 + tests/test_views/test_pins.py | 1 + tests/test_views/test_subscriptions.py | 1 + tests/test_views/test_threads.py | 1 + tests/test_views/test_users.py | 1 + 44 files changed, 2951 insertions(+), 61 deletions(-) create mode 100644 forum/api/mutes.py create mode 100644 forum/backends/mongodb/mutes.py create mode 100644 forum/management/commands/forum_create_mute_mongodb_indexes.py create mode 100644 forum/migrations/0006_add_discussion_mute_models.py create mode 100644 forum/serializers/mute.py create mode 100644 forum/views/mutes.py diff --git a/forum/admin.py b/forum/admin.py index c1ff0239..7a840360 100644 --- a/forum/admin.py +++ b/forum/admin.py @@ -1,20 +1,23 @@ """Admin module for forum.""" from django.contrib import admin + from forum.models import ( - ForumUser, - CourseStat, - CommentThread, + AbuseFlagger, Comment, + CommentThread, + CourseStat, + DiscussionMute, + DiscussionMuteException, EditHistory, - AbuseFlagger, + ForumUser, HistoricalAbuseFlagger, - ReadState, LastReadTime, - UserVote, - Subscription, - MongoContent, ModerationAuditLog, + MongoContent, + ReadState, + Subscription, + UserVote, ) @@ -149,6 +152,38 @@ class SubscriptionAdmin(admin.ModelAdmin): # type: ignore list_filter = ("source_content_type",) +@admin.register(DiscussionMute) +class DiscussionMuteAdmin(admin.ModelAdmin): # type: ignore + """Admin interface for DiscussionMute model.""" + + list_display = ( + "muted_user", + "muted_by", + "course_id", + "scope", + "reason", + "is_active", + "created", + "modified", + ) + search_fields = ( + "muted_user__username", + "muted_by__username", + "reason", + "course_id", + ) + list_filter = ("scope", "is_active", "created", "modified") + + +@admin.register(DiscussionMuteException) +class DiscussionMuteExceptionAdmin(admin.ModelAdmin): # type: ignore + """Admin interface for DiscussionMuteException model.""" + + list_display = ("muted_user", "exception_user", "course_id", "created") + search_fields = ("muted_user__username", "exception_user__username", "course_id") + list_filter = ("created",) + + @admin.register(MongoContent) class MongoContentAdmin(admin.ModelAdmin): # type: ignore """Admin interface for MongoContent model.""" diff --git a/forum/ai_moderation.py b/forum/ai_moderation.py index 5e9233e8..d4d70b00 100644 --- a/forum/ai_moderation.py +++ b/forum/ai_moderation.py @@ -4,7 +4,7 @@ import json import logging -from typing import Dict, Optional, Any +from typing import Any, Dict, Optional import requests from django.conf import settings @@ -216,9 +216,7 @@ def moderate_and_flag_content( } # Check if AI moderation is enabled # pylint: disable=import-outside-toplevel - from forum.toggles import ( - is_ai_moderation_enabled, - ) + from forum.toggles import is_ai_moderation_enabled course_key = CourseKey.from_string(course_id) if course_id else None if not is_ai_moderation_enabled(course_key): # type: ignore[no-untyped-call] diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..44b3c1e4 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -12,9 +12,14 @@ get_user_comments, update_comment, ) -from .flags import ( - update_comment_flag, - update_thread_flag, +from .flags import update_comment_flag, update_thread_flag +from .mutes import ( + get_all_muted_users_for_course, + get_muted_users, + get_user_mute_status, + mute_and_report_user, + mute_user, + unmute_user, ) from .pins import pin_thread, unpin_thread from .search import search_threads @@ -87,4 +92,10 @@ "update_user", "update_username", "update_users_in_course", + "mute_user", + "unmute_user", + "get_user_mute_status", + "get_muted_users", + "get_all_muted_users_for_course", + "mute_and_report_user", ] diff --git a/forum/api/mutes.py b/forum/api/mutes.py new file mode 100644 index 00000000..08db1a29 --- /dev/null +++ b/forum/api/mutes.py @@ -0,0 +1,222 @@ +""" +Native Python APIs for discussion moderation (mute/unmute). +""" + +from datetime import datetime +from typing import Any, Dict, Optional + +from forum.backend import get_backend +from forum.utils import ForumV2RequestError + + +def mute_user( + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, +) -> Dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + + Returns: + Dictionary containing mute record data + """ + try: + backend = get_backend(course_id)() + return backend.mute_user( + muted_user_id=muted_user_id, + muted_by_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + **kwargs, + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to mute user: {str(e)}") from e + + +def unmute_user( + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muted_by_id: Optional[str] = None, + **kwargs: Any, +) -> Dict[str, Any]: + """ + Unmute a user in discussions. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + muted_by_id: Optional filter by who performed the original mute + + Returns: + Dictionary containing unmute operation result + """ + try: + backend = get_backend(course_id)() + return backend.unmute_user( + muted_user_id=muted_user_id, + unmuted_by_id=unmuted_by_id, + course_id=course_id, + scope=scope, + muted_by_id=muted_by_id, + **kwargs, + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to unmute user: {str(e)}") from e + + +def get_user_mute_status( + user_id: str, course_id: str, viewer_id: str, **kwargs: Any +) -> Dict[str, Any]: + """ + Get mute status for a user in a course. + + Args: + user_id: ID of user to check + course_id: Course identifier + viewer_id: ID of user requesting the status + + Returns: + Dictionary containing mute status information + """ + try: + backend = get_backend(course_id)() + return backend.get_user_mute_status( + muted_user_id=user_id, + course_id=course_id, + requesting_user_id=viewer_id, + **kwargs, + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to get mute status: {str(e)}") from e + + +def get_muted_users( + muted_by_id: str, course_id: str, scope: str = "all", **kwargs: Any +) -> list[dict[str, Any]]: + """ + Get list of users muted by a specific user. + + Args: + muted_by_id: ID of the user who muted others + course_id: Course identifier + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + List of muted user records + """ + try: + backend = get_backend(course_id)() + return backend.get_muted_users( + moderator_id=muted_by_id, course_id=course_id, scope=scope, **kwargs + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to get muted users: {str(e)}") from e + + +def mute_and_report_user( + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, +) -> Dict[str, Any]: + """ + Mute a user and create a report against them in discussions. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Dictionary containing mute and report operation result + """ + try: + backend = get_backend(course_id)() + + # Mute the user + mute_result = backend.mute_user( + muted_user_id=muted_user_id, + muted_by_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + **kwargs, + ) + + # Create a basic report record (placeholder implementation) + # In a full implementation, this would integrate with a proper reporting system + report_result = { + "status": "success", + "report_id": f"report_{muted_user_id}_{muted_by_id}_{course_id}", + "reported_user_id": muted_user_id, + "reported_by_id": muted_by_id, + "course_id": course_id, + "reason": reason, + "created": datetime.utcnow().isoformat(), + } + + return { + "status": "success", + "message": "User muted and reported", + "mute_record": mute_result, + "report_record": report_result, + } + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to mute and report user: {str(e)}") from e + + +def get_all_muted_users_for_course( + course_id: str, + _requester_id: Optional[str] = None, + scope: str = "all", + **kwargs: Any, +) -> Dict[str, Any]: + """ + Get all muted users in a course (requires appropriate permissions). + + Args: + course_id: Course identifier + requester_id: ID of the user requesting the list (optional) + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + Dictionary containing list of all muted users in the course + """ + try: + backend = get_backend(course_id)() + return backend.get_all_muted_users_for_course( + course_id=course_id, scope=scope, **kwargs + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to get course muted users: {str(e)}") from e diff --git a/forum/api/subscriptions.py b/forum/api/subscriptions.py index f21639e0..f701ed67 100644 --- a/forum/api/subscriptions.py +++ b/forum/api/subscriptions.py @@ -9,11 +9,11 @@ from rest_framework.test import APIRequestFactory from forum.backend import get_backend +from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE from forum.pagination import ForumPagination from forum.serializers.subscriptions import SubscriptionSerializer from forum.serializers.thread import ThreadSerializer from forum.utils import ForumV2RequestError -from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE def validate_user_and_thread( diff --git a/forum/api/users.py b/forum/api/users.py index 71c3a36e..74b1c0fa 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -21,9 +21,9 @@ def get_user( course_id: Optional[str] = None, complete: Optional[bool] = False, ) -> dict[str, Any]: - """Get user data by user_id.""" """ Get users data by user_id. + Parameters: user_id (str): The ID of the requested User. params (str): attributes for user's data filteration. diff --git a/forum/backend.py b/forum/backend.py index bc2434dc..194f5113 100644 --- a/forum/backend.py +++ b/forum/backend.py @@ -12,9 +12,10 @@ def is_mysql_backend_enabled(course_id: str | None) -> bool: """ try: # pylint: disable=import-outside-toplevel - from forum.toggles import ENABLE_MYSQL_BACKEND from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey + + from forum.toggles import ENABLE_MYSQL_BACKEND except ImportError: return True diff --git a/forum/backends/backend.py b/forum/backends/backend.py index 8a5b9175..c91d96b3 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -476,3 +476,190 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: Retrieve all threads and comments authored by a specific user. """ raise NotImplementedError + + # Mute/Unmute functionality + @classmethod + def mute_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any + ) -> dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + + Returns: + Dictionary containing mute record data + """ + raise NotImplementedError + + @classmethod + def unmute_user( + cls, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muted_by_id: Optional[str] = None, + **kwargs: Any + ) -> dict[str, Any]: + """ + Unmute a user in discussions. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course identifier + scope: Unmute scope ('personal' or 'course') + muted_by_id: Optional filter by original muter (for personal mutes) + + Returns: + Dictionary containing unmute operation result + """ + raise NotImplementedError + + @classmethod + def get_user_mute_status( + cls, + muted_user_id: str, + course_id: str, + requesting_user_id: Optional[str] = None, + **kwargs: Any + ) -> dict[str, Any]: + """ + Get mute status for a user in a course. + + Args: + muted_user_id: ID of user to check + course_id: Course identifier + requesting_user_id: ID of user requesting the status + + Returns: + Dictionary containing mute status information + """ + raise NotImplementedError + + @classmethod + def get_muted_users( + cls, + moderator_id: str, + course_id: str, + scope: str = "personal", + active_only: bool = True, + **kwargs: Any + ) -> list[dict[str, Any]]: + """ + Get list of users muted by a moderator. + + Args: + moderator_id: ID of the moderator + course_id: Course identifier + scope: Mute scope filter + active_only: Whether to return only active mutes + + Returns: + List of muted user records + """ + raise NotImplementedError + + @classmethod + def create_mute_exception( + cls, muted_user_id: str, exception_user_id: str, course_id: str, **kwargs: Any + ) -> dict[str, Any]: + """ + Create a mute exception for course-wide mutes. + + Args: + muted_user_id: ID of the muted user + exception_user_id: ID of user creating exception + course_id: Course identifier + + Returns: + Dictionary containing exception data + """ + raise NotImplementedError + + @classmethod + def log_moderation_action( + cls, + action_type: str, + target_user_id: str, + moderator_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any + ) -> dict[str, Any]: + """ + Log a moderation action. + + Args: + action_type: Type of action (mute, unmute, mute_and_report) + target_user_id: ID of the target user + moderator_id: ID of the moderating user + course_id: Course identifier + scope: Action scope + reason: Optional reason + metadata: Additional action metadata + + Returns: + Dictionary containing log entry data + """ + raise NotImplementedError + + @classmethod + def mute_and_report_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any + ) -> dict[str, Any]: + """ + Mute a user and create a moderation report. + + Args: + muted_user_id: ID of user to mute and report + muted_by_id: ID of user performing the action + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Dictionary containing mute and report data + """ + raise NotImplementedError + + @classmethod + def get_all_muted_users_for_course( + cls, + course_id: str, + requester_id: Optional[str] = None, + scope: str = "all", + **kwargs: Any + ) -> dict[str, Any]: + """ + Get all muted users in a course. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + Dictionary containing list of muted users + """ + raise NotImplementedError diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 609a9a0e..29526f05 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -4,7 +4,8 @@ from datetime import datetime, timezone from typing import Any, Optional -from bson import ObjectId, errors as bson_errors +from bson import ObjectId +from bson import errors as bson_errors from django.core.exceptions import ObjectDoesNotExist from forum.backends.backend import AbstractBackend @@ -15,6 +16,7 @@ Subscriptions, Users, ) +from forum.backends.mongodb.mutes import DiscussionModerationLogs, DiscussionMutes from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -1714,7 +1716,6 @@ def create_user_pipeline( ] return pipeline - # pylint: disable=E1121 @classmethod def get_paginated_user_stats( cls, course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] @@ -1811,3 +1812,336 @@ def unflag_content_as_spam(content_type: str, content_id: str) -> int: return 0 return model.update(content_id, is_spam=False) + + # Mute/Unmute Methods for MongoDB Backend + @classmethod + def mute_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, + ) -> dict[str, Any]: + """ + Mute a user using MongoDB backend. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + + Returns: + Dictionary containing mute record data + """ + try: + mutes = DiscussionMutes() + logs = DiscussionModerationLogs() + + # Create the mute record + mute_doc = mutes.create_mute( + muted_user_id=muted_user_id, + muted_by_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + # Log the action + logs.log_action( + action_type="mute", + target_user_id=muted_user_id, + moderator_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + metadata={"backend": "mongodb"}, + ) + + return mute_doc + + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to mute user: {str(e)}") from e + + @classmethod + def unmute_user( + cls, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muted_by_id: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Unmute a user using MongoDB backend. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course identifier + scope: Unmute scope ('personal' or 'course') + muted_by_id: Original muter ID (for personal unmutes) + + Returns: + Dictionary containing unmute result + """ + try: + mutes = DiscussionMutes() + logs = DiscussionModerationLogs() + + # Deactivate the mute + result = mutes.deactivate_mutes( + muted_user_id=muted_user_id, + unmuted_by_id=unmuted_by_id, + course_id=course_id, + scope=scope, + muted_by_id=muted_by_id, + ) + + # Log the action + logs.log_action( + action_type="unmute", + target_user_id=muted_user_id, + moderator_id=unmuted_by_id, + course_id=course_id, + scope=scope, + metadata={"backend": "mongodb"}, + ) + + return result + + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + raise ForumV2RequestError(f"Failed to unmute user: {str(e)}") from e + + @classmethod + def mute_and_report_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, + ) -> dict[str, Any]: + """ + Mute a user and create a moderation report using MongoDB backend. + + Args: + muted_user_id: ID of user to mute and report + muted_by_id: ID of user performing the action + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Dictionary containing mute and report data + """ + try: + # First mute the user + mute_result = cls.mute_user( + muted_user_id=muted_user_id, + muted_by_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + # Log the mute_and_report action + logs = DiscussionModerationLogs() + logs.log_action( + action_type="mute_and_report", + target_user_id=muted_user_id, + moderator_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + metadata={ + "backend": "mongodb", + "reported": True, + "mute_id": str(mute_result.get("_id")), + }, + ) + + # Add reporting flag to indicate this was also reported + mute_result["reported"] = True + mute_result["action"] = "mute_and_report" + + return mute_result + + except Exception as e: + raise ForumV2RequestError( + f"Failed to mute and report user: {str(e)}" + ) from e + + @classmethod + def get_user_mute_status( + cls, + muted_user_id: str, + course_id: str, + requesting_user_id: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Get mute status for a user using MongoDB backend. + + Args: + muted_user_id: ID of user to check + course_id: Course identifier + requesting_user_id: ID of user requesting the status + + Returns: + Dictionary containing mute status information + """ + try: + mutes = DiscussionMutes() + return mutes.get_user_mute_status( + user_id=muted_user_id, + course_id=course_id, + viewer_id=requesting_user_id or "", # Handle None case + ) + + except Exception as e: + raise ForumV2RequestError(f"Failed to get mute status: {str(e)}") from e + + @classmethod + def get_all_muted_users_for_course( + cls, + course_id: str, + requester_id: Optional[str] = None, + scope: str = "all", + **kwargs: Any, + ) -> dict[str, Any]: + """ + Get all muted users in a course using MongoDB backend. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + Dictionary containing list of muted users + """ + try: + mutes = DiscussionMutes() + muted_users = mutes.get_all_muted_users_for_course( + course_id=course_id, requester_id=requester_id, scope=scope + ) + + return { + "course_id": course_id, + "scope": scope, + "muted_users": muted_users, + "total_count": len(muted_users), + "backend": "mongodb", + } + + except Exception as e: + raise ForumV2RequestError(f"Failed to get muted users: {str(e)}") from e + + @classmethod + def get_muted_users( + cls, + moderator_id: str, + course_id: str, + scope: str = "personal", + active_only: bool = True, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """ + Get list of users muted by a moderator using MongoDB backend. + + Args: + moderator_id: ID of the moderator + course_id: Course identifier + scope: Mute scope filter + active_only: Whether to return only active mutes + + Returns: + List of muted user records + """ + try: + # MongoDB implementation placeholder + return [] + except Exception as e: + raise ForumV2RequestError(f"Failed to get muted users: {str(e)}") from e + + @classmethod + def create_mute_exception( + cls, muted_user_id: str, exception_user_id: str, course_id: str, **kwargs: Any + ) -> dict[str, Any]: + """ + Create a mute exception for course-wide mutes using MongoDB backend. + + Args: + muted_user_id: ID of the muted user + exception_user_id: ID of user creating exception + course_id: Course identifier + + Returns: + Dictionary containing exception data + """ + try: + # MongoDB implementation placeholder + return { + "muted_user_id": muted_user_id, + "exception_user_id": exception_user_id, + "course_id": course_id, + "backend": "mongodb", + } + except Exception as e: + raise ForumV2RequestError( + f"Failed to create mute exception: {str(e)}" + ) from e + + @classmethod + def log_moderation_action( + cls, + action_type: str, + target_user_id: str, + moderator_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Log a moderation action using MongoDB backend. + + Args: + action_type: Type of action (mute, unmute, mute_and_report) + target_user_id: ID of the target user + moderator_id: ID of the moderating user + course_id: Course identifier + scope: Action scope + reason: Optional reason + metadata: Additional action metadata + + Returns: + Dictionary containing log entry data + """ + try: + # MongoDB implementation placeholder + return { + "action_type": action_type, + "target_user_id": target_user_id, + "moderator_id": moderator_id, + "course_id": course_id, + "scope": scope, + "reason": reason, + "metadata": metadata or {}, + "backend": "mongodb", + } + except Exception as e: + raise ForumV2RequestError( + f"Failed to log moderation action: {str(e)}" + ) from e diff --git a/forum/backends/mongodb/base_model.py b/forum/backends/mongodb/base_model.py index fbc770ad..14024ba6 100644 --- a/forum/backends/mongodb/base_model.py +++ b/forum/backends/mongodb/base_model.py @@ -34,6 +34,36 @@ def __get_database(cls) -> Database: cls.MONGODB_DATABASE = get_database() return cls.MONGODB_DATABASE + @staticmethod + def _serialize_objectid(doc: dict[str, Any]) -> dict[str, Any]: + """ + Convert ObjectId fields to strings for JSON serialization. + + Args: + doc: MongoDB document + + Returns: + Document with serialized ObjectIds + """ + if "_id" in doc and isinstance(doc["_id"], ObjectId): + doc["_id"] = str(doc["_id"]) + return doc + + @classmethod + def _serialize_objectids_in_list( + cls, docs: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Convert ObjectId fields to strings in a list of documents. + + Args: + docs: List of MongoDB documents + + Returns: + List of documents with serialized ObjectIds + """ + return [cls._serialize_objectid(doc.copy()) for doc in docs] + def override_query(self, query: dict[str, Any]) -> dict[str, Any]: """Override Query""" return query diff --git a/forum/backends/mongodb/contents.py b/forum/backends/mongodb/contents.py index 99d5c264..3daa2b6d 100644 --- a/forum/backends/mongodb/contents.py +++ b/forum/backends/mongodb/contents.py @@ -237,7 +237,7 @@ def update( historical_abuse_flaggers: Optional[list[str]] = None, body: Optional[str] = None, title: Optional[str] = None, - **kwargs: Any + **_kwargs: Any ) -> int: """ Updates a contents document in the database based on the provided _id. diff --git a/forum/backends/mongodb/mutes.py b/forum/backends/mongodb/mutes.py new file mode 100644 index 00000000..e9a44c3f --- /dev/null +++ b/forum/backends/mongodb/mutes.py @@ -0,0 +1,462 @@ +"""Discussion moderation models for MongoDB backend.""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from bson import ObjectId +from pymongo.errors import DuplicateKeyError + +from forum.backends.mongodb.base_model import MongoBaseModel + + +class DiscussionMutes(MongoBaseModel): + """ + MongoDB model for discussion user mutes. + Supports both personal and course-wide mutes. + """ + + COLLECTION_NAME: str = "discussion_mutes" + + def get_active_mutes( + self, + muted_user_id: str, + course_id: str, + muted_by_id: Optional[str] = None, + scope: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + Get active mutes for a user in a course. + + Args: + muted_user_id: ID of the muted user + course_id: Course identifier + muted_by_id: ID of user who performed the mute (optional) + scope: Scope filter (personal/course) (optional) + + Returns: + List of active mute documents with serialized ObjectIds + """ + query = { + "muted_user_id": muted_user_id, + "course_id": course_id, + "is_active": True, + } + + if muted_by_id: + query["muted_by_id"] = muted_by_id + if scope: + query["scope"] = scope + + # Get mute documents and serialize ObjectId fields for JSON compatibility + mute_docs = list(self._collection.find(query)) + return self._serialize_objectids_in_list(mute_docs) + + def create_mute( + self, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + ) -> Dict[str, Any]: + """ + Create a new mute record. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for muting + + Returns: + Created mute document + """ + # Check for existing active mute + existing = self.get_active_mutes( + muted_user_id=muted_user_id, + course_id=course_id, + muted_by_id=muted_by_id if scope == "personal" else None, + scope=scope, + ) + + if existing: + raise ValueError("User is already muted in this scope") + + mute_doc = { + "_id": ObjectId(), + "muted_user_id": muted_user_id, + "muted_by_id": muted_by_id, + "course_id": course_id, + "scope": scope, + "reason": reason, + "is_active": True, + "created_at": datetime.utcnow(), + "modified_at": datetime.utcnow(), + "muted_at": datetime.utcnow(), + "unmuted_at": None, + "unmuted_by_id": None, + } + + try: + result = self._collection.insert_one(mute_doc) + mute_doc["_id"] = str(result.inserted_id) + return mute_doc + except DuplicateKeyError as e: + raise ValueError("Duplicate mute record") from e + + def deactivate_mutes( + self, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muted_by_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Deactivate (unmute) existing mute records. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course identifier + scope: Unmute scope ('personal' or 'course') + muted_by_id: Original muter ID (for personal unmutes) + + Returns: + Result of unmute operation + """ + query = { + "muted_user_id": muted_user_id, + "course_id": course_id, + "scope": scope, + "is_active": True, + } + + if scope == "personal" and muted_by_id: + query["muted_by_id"] = muted_by_id + + update_doc = { + "$set": { + "is_active": False, + "unmuted_by_id": unmuted_by_id, + "unmuted_at": datetime.utcnow(), + "modified_at": datetime.utcnow(), + } + } + + result = self._collection.update_many(query, update_doc) + + if result.matched_count == 0: + raise ValueError("No active mute found") + + return { + "message": "User unmuted successfully", + "muted_user_id": muted_user_id, + "unmuted_by_id": unmuted_by_id, + "course_id": course_id, + "scope": scope, + "modified_count": result.modified_count, + } + + def get_all_muted_users_for_course( + self, course_id: str, requester_id: Optional[str] = None, scope: str = "all" + ) -> List[Dict[str, Any]]: + """ + Get all muted users in a course. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list (for personal mutes) + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + List of active mute records with serialized ObjectIds + """ + query = {"course_id": course_id, "is_active": True} + + if scope == "personal": + query["scope"] = "personal" + if requester_id: + query["muted_by_id"] = requester_id + elif scope == "course": + query["scope"] = "course" + + # Get mute documents and serialize ObjectId fields for JSON compatibility + mute_docs = list(self._collection.find(query)) + return self._serialize_objectids_in_list(mute_docs) + + def get_user_mute_status( + self, user_id: str, course_id: str, viewer_id: str + ) -> Dict[str, Any]: + """ + Get comprehensive mute status for a user. + + Args: + user_id: ID of user to check + course_id: Course identifier + viewer_id: ID of user requesting the status + + Returns: + Dictionary with mute status information + """ + # Check personal mutes (viewer → user) + personal_mutes = self.get_active_mutes( + muted_user_id=user_id, + course_id=course_id, + muted_by_id=viewer_id, + scope="personal", + ) + + # Check course-wide mutes + course_mutes = self.get_active_mutes( + muted_user_id=user_id, course_id=course_id, scope="course" + ) + + # Check for exceptions (viewer has unmuted this user for themselves) + exceptions = self._check_exceptions(user_id, viewer_id, course_id) + + is_personally_muted = len(personal_mutes) > 0 + is_course_muted = len(course_mutes) > 0 and not exceptions + + return { + "user_id": user_id, + "course_id": course_id, + "is_muted": is_personally_muted or is_course_muted, + "personal_mute": is_personally_muted, + "course_mute": is_course_muted, + "has_exception": exceptions, + "mute_details": personal_mutes + course_mutes, + } + + def _check_exceptions( + self, muted_user_id: str, viewer_id: str, course_id: str + ) -> bool: + """ + Check if viewer has an exception for a course-wide muted user. + + Args: + muted_user_id: ID of muted user + viewer_id: ID of viewer + course_id: Course identifier + + Returns: + True if exception exists, False otherwise + """ + exceptions_model = DiscussionMuteExceptions() + return exceptions_model.has_exception(muted_user_id, viewer_id, course_id) + + +class DiscussionMuteExceptions(MongoBaseModel): + """ + MongoDB model for course-wide mute exceptions. + Allows specific users to unmute course-wide muted users for themselves. + """ + + COLLECTION_NAME: str = "discussion_mute_exceptions" + + def create_exception( + self, muted_user_id: str, exception_user_id: str, course_id: str + ) -> Dict[str, Any]: + """ + Create a mute exception for a user. + + Args: + muted_user_id: ID of the course-wide muted user + exception_user_id: ID of user creating the exception + course_id: Course identifier + + Returns: + Created exception document + """ + # Check if course-wide mute exists + mutes_model = DiscussionMutes() + course_mutes = mutes_model.get_active_mutes( + muted_user_id=muted_user_id, course_id=course_id, scope="course" + ) + + if not course_mutes: + raise ValueError("No active course-wide mute found for this user") + + exception_doc = { + "_id": ObjectId(), + "muted_user_id": muted_user_id, + "exception_user_id": exception_user_id, + "course_id": course_id, + "created_at": datetime.utcnow(), + "modified_at": datetime.utcnow(), + } + + # Use upsert to handle duplicates gracefully + result = self._collection.update_one( + { + "muted_user_id": muted_user_id, + "exception_user_id": exception_user_id, + "course_id": course_id, + }, + {"$set": exception_doc}, + upsert=True, + ) + + if result.upserted_id: + exception_doc["_id"] = str(result.upserted_id) + + return exception_doc + + def remove_exception( + self, muted_user_id: str, exception_user_id: str, course_id: str + ) -> bool: + """ + Remove a mute exception. + + Args: + muted_user_id: ID of the muted user + exception_user_id: ID of user removing the exception + course_id: Course identifier + + Returns: + True if exception was removed, False if not found + """ + result = self._collection.delete_one( + { + "muted_user_id": muted_user_id, + "exception_user_id": exception_user_id, + "course_id": course_id, + } + ) + + return result.deleted_count > 0 + + def has_exception( + self, muted_user_id: str, exception_user_id: str, course_id: str + ) -> bool: + """ + Check if a mute exception exists. + + Args: + muted_user_id: ID of the muted user + exception_user_id: ID of user to check + course_id: Course identifier + + Returns: + True if exception exists, False otherwise + """ + count = self._collection.count_documents( + { + "muted_user_id": muted_user_id, + "exception_user_id": exception_user_id, + "course_id": course_id, + } + ) + + return count > 0 + + def get_exceptions_for_course(self, course_id: str) -> List[Dict[str, Any]]: + """ + Get all mute exceptions in a course. + + Args: + course_id: Course identifier + + Returns: + List of exception documents with serialized ObjectIds + """ + # Get exception documents and serialize ObjectId fields for JSON compatibility + exception_docs = list(self._collection.find({"course_id": course_id})) + return self._serialize_objectids_in_list(exception_docs) + + +class DiscussionModerationLogs(MongoBaseModel): + """ + MongoDB model for logging moderation actions. + """ + + COLLECTION_NAME: str = "discussion_moderation_logs" + + def log_action( + self, + action_type: str, + target_user_id: str, + moderator_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + metadata: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Log a moderation action. + + Args: + action_type: Type of action ('mute', 'unmute', 'mute_and_report') + target_user_id: ID of user who was targeted + moderator_id: ID of user performing the action + course_id: Course identifier + scope: Action scope ('personal' or 'course') + reason: Optional reason for the action + metadata: Additional metadata for the action + + Returns: + Created log document + """ + log_doc = { + "_id": ObjectId(), + "action_type": action_type, + "target_user_id": target_user_id, + "moderator_id": moderator_id, + "course_id": course_id, + "scope": scope, + "reason": reason, + "metadata": metadata or {}, + "timestamp": datetime.utcnow(), + } + + result = self._collection.insert_one(log_doc) + log_doc["_id"] = str(result.inserted_id) + + return log_doc + + def get_logs_for_user( + self, user_id: str, course_id: Optional[str] = None, limit: int = 50 + ) -> List[Dict[str, Any]]: + """ + Get moderation logs for a user. + + Args: + user_id: ID of user to get logs for + course_id: Optional course filter + limit: Maximum number of logs to return + + Returns: + List of log documents with serialized ObjectIds + """ + query = {"target_user_id": user_id} + if course_id: + query["course_id"] = course_id + + # Get log documents and serialize ObjectId fields for JSON compatibility + log_docs = list(self._collection.find(query).sort("timestamp", -1).limit(limit)) + + return self._serialize_objectids_in_list(log_docs) + + def get_logs_for_course( + self, course_id: str, action_type: Optional[str] = None, limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + Get moderation logs for a course. + + Args: + course_id: Course identifier + action_type: Optional action type filter + limit: Maximum number of logs to return + + Returns: + List of log documents with serialized ObjectIds + """ + query = {"course_id": course_id} + if action_type: + query["action_type"] = action_type + + # Get log documents and serialize ObjectId fields for JSON compatibility + log_docs = list(self._collection.find(query).sort("timestamp", -1).limit(limit)) + + return self._serialize_objectids_in_list(log_docs) diff --git a/forum/backends/mysql/__init__.py b/forum/backends/mysql/__init__.py index 72a7b01c..6fe82a4f 100644 --- a/forum/backends/mysql/__init__.py +++ b/forum/backends/mysql/__init__.py @@ -1,5 +1,5 @@ """MySQL backend for forum v2.""" -from forum.backends.mysql.models import Content, CommentThread, Comment +from forum.backends.mysql.models import Comment, CommentThread, Content MODEL_INDICES: tuple[type[Content], ...] = (CommentThread, Comment) diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index c8633476..2e61b8aa 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -3,15 +3,15 @@ import math import random from datetime import timedelta -from typing import Any, Optional, Union +from typing import Any, Dict, Optional, Union from django.contrib.auth.models import User # pylint: disable=E5142 from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db.models import ( - Count, Case, + Count, Exists, F, IntegerField, @@ -19,8 +19,8 @@ OuterRef, Q, Subquery, - When, Sum, + When, ) from django.utils import timezone from rest_framework import status @@ -32,6 +32,8 @@ Comment, CommentThread, CourseStat, + DiscussionMute, + DiscussionMuteException, EditHistory, ForumUser, HistoricalAbuseFlagger, @@ -586,7 +588,7 @@ def get_sort_criteria(sort_key: str) -> list[str]: return [] # TODO: Make this function modular - # pylint: disable=too-many-nested-blocks,too-many-statements + # pylint: disable=too-many-statements @classmethod def handle_threads_query( cls, @@ -1216,9 +1218,7 @@ def unsubscribe_all(user_id: str) -> None: # Kept method signature same as mongo implementation @staticmethod - def retire_all_content( - user_id: str, username: str - ) -> None: # pylint: disable=W0613 + def retire_all_content(user_id: str, username: str) -> None: """Retire all content from user.""" comments = Comment.objects.filter(author__pk=user_id) for comment in comments: @@ -1561,6 +1561,7 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: } return commentable_counts + # pylint: disable=too-many-statements @staticmethod def update_comment(comment_id: str, **kwargs: Any) -> int: """Updates a comment in the database.""" @@ -1792,6 +1793,7 @@ def create_thread(data: dict[str, Any]) -> str: ) return str(new_thread.pk) + # pylint: disable=too-many-statements @staticmethod def update_thread( thread_id: str, @@ -2255,3 +2257,322 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: return cls.update_thread(content_id, **update_data) else: return cls.update_comment(content_id, **update_data) + + # Mute/Unmute Methods for MySQL Backend + @classmethod + def mute_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + + Returns: + Dictionary containing mute record data + """ + try: + muted_user = User.objects.get(pk=int(muted_user_id)) + muted_by_user = User.objects.get(pk=int(muted_by_id)) + + # Check if mute already exists + existing_mute = DiscussionMute.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + + if scope == DiscussionMute.Scope.PERSONAL: + existing_mute = existing_mute.filter(muted_by=muted_by_user) + + if existing_mute.exists(): + raise ValueError("User is already muted in this scope") + + # Create the mute record + mute = DiscussionMute.objects.create( + muted_user=muted_user, + muted_by=muted_by_user, + course_id=course_id, + scope=scope, + reason=reason, + ) + + return mute.to_dict() + + except User.DoesNotExist as e: + raise ValueError(f"User not found: {e}") from e + except Exception as e: + raise ValueError(f"Failed to mute user: {e}") from e + + @classmethod + def unmute_user( + cls, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muted_by_id: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Unmute a user in discussions. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course identifier + scope: Unmute scope ('personal' or 'course') + muted_by_id: Original muter ID (for personal unmutes) + + Returns: + Dictionary containing unmute result + """ + try: + muted_user = User.objects.get(pk=int(muted_user_id)) + unmuted_by_user = User.objects.get(pk=int(unmuted_by_id)) + + # Find the active mute + mute_query = DiscussionMute.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + + if scope == DiscussionMute.Scope.PERSONAL and muted_by_id: + muted_by_user = User.objects.get(pk=int(muted_by_id)) + mute_query = mute_query.filter(muted_by=muted_by_user) + + mute = mute_query.first() + if not mute: + raise ValueError("No active mute found") + + # Deactivate the mute + mute.is_active = False + mute.unmuted_by = unmuted_by_user + mute.unmuted_at = timezone.now() + mute.save() + + return { + "message": "User unmuted successfully", + "muted_user_id": str(muted_user.pk), + "unmuted_by_id": str(unmuted_by_user.pk), + "course_id": course_id, + "scope": scope, + } + + except User.DoesNotExist as e: + raise ValueError(f"User not found: {e}") from e + except Exception as e: + raise ValueError(f"Failed to unmute user: {e}") from e + + @classmethod + def mute_and_report_user( + cls, + muted_user_id: str, + muted_by_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Mute a user and create a moderation report. + + Args: + muted_user_id: ID of user to mute and report + muted_by_id: ID of user performing the action + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Dictionary containing mute and report data + """ + # First mute the user + mute_result = cls.mute_user( + muted_user_id=muted_user_id, + muted_by_id=muted_by_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + # Add reporting flag to indicate this was also reported + mute_result["reported"] = True + mute_result["action"] = "mute_and_report" + + return mute_result + + @classmethod + def get_user_mute_status( + cls, + muted_user_id: str, + course_id: str, + requesting_user_id: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Get mute status for a user. + + Args: + user_id: ID of user to check + course_id: Course identifier + viewer_id: ID of user requesting the status + + Returns: + Dictionary containing mute status information + """ + try: + user = User.objects.get(pk=int(muted_user_id)) + viewer = ( + User.objects.get(pk=int(requesting_user_id)) + if requesting_user_id + else None + ) + + # Check for active mutes + personal_mutes = DiscussionMute.objects.filter( + muted_user=user, + muted_by=viewer, + course_id=course_id, + scope=DiscussionMute.Scope.PERSONAL, + is_active=True, + ) + + course_mutes = DiscussionMute.objects.filter( + muted_user=user, + course_id=course_id, + scope=DiscussionMute.Scope.COURSE, + is_active=True, + ) + + # Check for exceptions + has_exception = DiscussionMuteException.objects.filter( + muted_user=user, exception_user=viewer, course_id=course_id + ).exists() + + is_personally_muted = personal_mutes.exists() + is_course_muted = course_mutes.exists() and not has_exception + + return { + "user_id": muted_user_id, + "course_id": course_id, + "is_muted": is_personally_muted or is_course_muted, + "personal_mute": is_personally_muted, + "course_mute": is_course_muted, + "has_exception": has_exception, + "mute_details": [mute.to_dict() for mute in personal_mutes] + + [mute.to_dict() for mute in course_mutes], + } + + except User.DoesNotExist as e: + raise ValueError(f"User not found: {e}") from e + except Exception as e: + raise ValueError(f"Failed to get mute status: {e}") from e + + @classmethod + def get_all_muted_users_for_course( + cls, + course_id: str, + requester_id: Optional[str] = None, + scope: str = "all", + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Get all muted users in a course. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + Dictionary containing list of muted users + """ + try: + query = DiscussionMute.objects.filter(course_id=course_id, is_active=True) + + if scope == "personal": + query = query.filter(scope=DiscussionMute.Scope.PERSONAL) + if requester_id: + query = query.filter(muted_by__pk=int(requester_id)) + elif scope == "course": + query = query.filter(scope=DiscussionMute.Scope.COURSE) + + muted_users = [] + for mute in query.select_related("muted_user", "muted_by"): + mute_data = mute.to_dict() + muted_users.append(mute_data) + + return { + "course_id": course_id, + "scope": scope, + "muted_users": muted_users, + "total_count": len(muted_users), + } + + except Exception as e: + raise ValueError(f"Failed to get muted users: {e}") from e + + @classmethod + def create_mute_exception( + cls, muted_user_id: str, exception_user_id: str, course_id: str, **kwargs: Any + ) -> dict[str, Any]: + """Create a mute exception for course-wide mutes.""" + # TODO: Implement mute exception logic when needed + raise NotImplementedError("Mute exceptions not yet implemented") + + @classmethod + def get_muted_users( + cls, + moderator_id: str, + course_id: str, + scope: str = "personal", + active_only: bool = True, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """Get list of users muted by a moderator.""" + try: + queryset = DiscussionMute.objects.filter( + course_id=course_id, muted_by_id=moderator_id, scope=scope + ) + if active_only: + queryset = queryset.filter(is_active=True) + + return [mute.to_dict() for mute in queryset] + except Exception as e: + raise ValueError(f"Failed to get muted users for moderator: {e}") from e + + @classmethod + def log_moderation_action( + cls, + action_type: str, + target_user_id: str, + moderator_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Log a moderation action.""" + # TODO: Implement moderation logging when needed + log_entry = { + "action_type": action_type, + "target_user_id": target_user_id, + "moderator_id": moderator_id, + "course_id": course_id, + "scope": scope, + "reason": reason, + "metadata": metadata or {}, + "timestamp": timezone.now().isoformat(), + } + # For now, just return the log entry data + return log_entry diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index e149daa6..c9e89416 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import User # pylint: disable=E5142 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.db.models import QuerySet from django.utils import timezone @@ -796,6 +797,10 @@ class ModerationAuditLog(models.Model): ("flagged", "Content Flagged"), ("soft_deleted", "Content Soft Deleted"), ("no_action", "No Action Taken"), + # ---- ADDED: discussion moderation actions ---- + ("mute", "Mute"), + ("unmute", "Unmute"), + ("mute_and_report", "Mute and Report"), ] # Only spam classifications since we don't store non-spam entries @@ -849,6 +854,28 @@ class ModerationAuditLog(models.Model): help_text="Original author of the moderated content", ) + # ---- ADDED: fields required for mute moderation ---- + course_id: models.CharField[str, str] = models.CharField( + max_length=255, + blank=True, + help_text="Course where the moderation action was performed", + db_index=True, + ) + scope: models.CharField[str, str] = models.CharField( + max_length=10, + blank=True, + help_text="Scope of mute action (personal or course)", + ) + reason: models.TextField[str, str] = models.TextField( + blank=True, + help_text="Optional reason for mute/unmute action", + ) + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Additional metadata for mute moderation", + ) + def to_dict(self) -> dict[str, Any]: """Return a dictionary representation of the model.""" return { @@ -878,4 +905,200 @@ class Meta: models.Index(fields=["classification"]), models.Index(fields=["original_author"]), models.Index(fields=["moderator"]), + models.Index(fields=["course_id"]), + ] + + +class DiscussionMute(models.Model): + """ + Tracks muted users in discussions. + A mute can be personal or course-wide. + """ + + class Scope(models.TextChoices): + PERSONAL = "personal", "Personal" + COURSE = "course", "Course-wide" + + muted_user: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="forum_muted_by_users", + help_text="User being muted", + db_index=True, + ) + muted_by: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="forum_muted_users", + help_text="User performing the mute", + db_index=True, + ) + unmuted_by: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="forum_mute_unactions", + help_text="User who performed the unmute action", + ) + course_id: models.CharField[str, str] = models.CharField( + max_length=255, db_index=True, help_text="Course in which mute applies" + ) + scope: models.CharField[str, str] = models.CharField( + max_length=10, + choices=Scope.choices, + default=Scope.PERSONAL, + help_text="Scope of the mute (personal or course-wide)", + db_index=True, + ) + reason: models.TextField[str, str] = models.TextField( + blank=True, help_text="Optional reason for muting" + ) + is_active: models.BooleanField[bool, bool] = models.BooleanField( + default=True, help_text="Whether the mute is currently active" + ) + + created: models.DateTimeField[datetime, datetime] = models.DateTimeField( + auto_now_add=True + ) + modified: models.DateTimeField[datetime, datetime] = models.DateTimeField( + auto_now=True + ) + muted_at: models.DateTimeField[datetime, datetime] = models.DateTimeField( + auto_now_add=True + ) + unmuted_at: models.DateTimeField[Optional[datetime], datetime] = ( + models.DateTimeField(null=True, blank=True) + ) + + class Meta: + app_label = "forum" + db_table = "forum_discussion_user_mute" + constraints = [ + # Only one active personal mute per (muted_by → muted_user) in a course + models.UniqueConstraint( + fields=["muted_user", "muted_by", "course_id", "scope"], + condition=models.Q(is_active=True, scope="personal"), + name="forum_unique_active_personal_mute", + ), + # Only one active course-wide mute per user per course + models.UniqueConstraint( + fields=["muted_user", "course_id"], + condition=models.Q(is_active=True, scope="course"), + name="forum_unique_active_course_mute", + ), + ] + + indexes = [ + models.Index(fields=["muted_user", "course_id", "is_active"]), + models.Index(fields=["muted_by", "course_id", "scope"]), + models.Index(fields=["scope", "course_id", "is_active"]), ] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the model.""" + return { + "_id": str(self.pk), + "muted_user_id": str(self.muted_user.pk), + "muted_user_username": self.muted_user.username, + "muted_by_id": str(self.muted_by.pk), + "muted_by_username": self.muted_by.username, + "unmuted_by_id": str(self.unmuted_by.pk) if self.unmuted_by else None, + "unmuted_by_username": ( + self.unmuted_by.username if self.unmuted_by else None + ), + "course_id": self.course_id, + "scope": self.scope, + "reason": self.reason, + "is_active": self.is_active, + "created": self.created.isoformat() if self.created else None, + "modified": self.modified.isoformat() if self.modified else None, + "muted_at": self.muted_at.isoformat() if self.muted_at else None, + "unmuted_at": self.unmuted_at.isoformat() if self.unmuted_at else None, + } + + def clean(self) -> None: + """Additional validation depending on mute scope.""" + + # Personal mute must have a muted_by different from muted_user + if self.scope == self.Scope.PERSONAL: + if self.muted_by == self.muted_user: + raise ValidationError("Personal mute cannot be self-applied.") + + # Course-wide mute must not be self-applied + if self.scope == self.Scope.COURSE: + if self.muted_by == self.muted_user: + raise ValidationError("Course-wide mute cannot be self-applied.") + + def __str__(self) -> str: + return f"{self.muted_by} muted {self.muted_user} in {self.course_id} ({self.scope})" + + +class DiscussionMuteException(models.Model): + """ + Per-user exception for course-wide mutes. + Allows a specific user to unmute someone while the rest of the course remains muted. + """ + + muted_user: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="forum_mute_exceptions_for", + help_text="User who is globally muted in this course", + db_index=True, + ) + exception_user: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="forum_mute_exceptions", + help_text="User who unmuted the muted_user for themselves", + db_index=True, + ) + course_id: models.CharField[str, str] = models.CharField( + max_length=255, + help_text="Course where the exception applies", + db_index=True, + ) + created: models.DateTimeField[datetime, datetime] = models.DateTimeField( + auto_now_add=True + ) + modified: models.DateTimeField[datetime, datetime] = models.DateTimeField( + auto_now=True + ) + + class Meta: + app_label = "forum" + db_table = "forum_discussion_mute_exception" + unique_together = [["muted_user", "exception_user", "course_id"]] + indexes = [ + models.Index(fields=["muted_user", "course_id"]), + models.Index(fields=["exception_user", "course_id"]), + ] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the model.""" + return { + "_id": str(self.pk), + "muted_user_id": str(self.muted_user.pk), + "muted_user_username": self.muted_user.username, + "exception_user_id": str(self.exception_user.pk), + "exception_user_username": self.exception_user.username, + "course_id": self.course_id, + "created": self.created.isoformat() if self.created else None, + "modified": self.modified.isoformat() if self.modified else None, + } + + def clean(self) -> None: + """Ensure exception is only created if a course-wide mute is active.""" + + has_coursewide_mute = DiscussionMute.objects.filter( + muted_user=self.muted_user, + course_id=self.course_id, + scope=DiscussionMute.Scope.COURSE, + is_active=True, + ).exists() + + if not has_coursewide_mute: + raise ValidationError( + "Exception can only be created for an active course-wide mute." + ) diff --git a/forum/handlers.py b/forum/handlers.py index 2ef1578b..2ef13f6a 100644 --- a/forum/handlers.py +++ b/forum/handlers.py @@ -4,12 +4,13 @@ import logging from typing import Any -from django.db.models.signals import post_save, post_delete + +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver +from forum.models import Comment, CommentThread from forum.search import get_document_search_backend from forum.utils import get_str_value_from_collection -from forum.models import Comment, CommentThread log = logging.getLogger(__name__) @@ -102,7 +103,7 @@ def handle_comment_updated(sender: Any, **kwargs: dict[str, Any]) -> None: @receiver(post_delete, sender=CommentThread) @receiver(post_delete, sender=Comment) -def handle_deletion(sender: Any, instance: Any, **kwargs: dict[str, Any]) -> None: +def handle_deletion(sender: Any, instance: Any, **_kwargs: dict[str, Any]) -> None: """ Handle the deletion of a comment thread or comment from the MySQL database. @@ -119,7 +120,7 @@ def handle_deletion(sender: Any, instance: Any, **kwargs: dict[str, Any]) -> Non @receiver(post_save, sender=CommentThread) @receiver(post_save, sender=Comment) def handle_comment_thread_and_comment( - sender: Any, instance: Any, created: bool, **kwargs: dict[str, Any] + sender: Any, instance: Any, created: bool, **_kwargs: dict[str, Any] ) -> None: """ Handle the insertion or update of a comment thread or comment in the MySQL database. diff --git a/forum/management/commands/delete_unused_forum_indices.py b/forum/management/commands/delete_unused_forum_indices.py index 613de964..685b1d70 100644 --- a/forum/management/commands/delete_unused_forum_indices.py +++ b/forum/management/commands/delete_unused_forum_indices.py @@ -14,7 +14,7 @@ class Command(BaseCommand): "Delete all Elasticsearch indices that are not the latest for each model type." ) - def handle(self, *args: list[str], **kwargs: dict[str, str]) -> None: + def handle(self, *_args: list[str], **_kwargs: dict[str, str]) -> None: """ Handles the execution of the delete_unused_forum_indices command. diff --git a/forum/management/commands/forum_create_mongodb_indexes.py b/forum/management/commands/forum_create_mongodb_indexes.py index efa28814..d82d84f9 100644 --- a/forum/management/commands/forum_create_mongodb_indexes.py +++ b/forum/management/commands/forum_create_mongodb_indexes.py @@ -12,7 +12,7 @@ class Command(BaseCommand): help = "Create or Update indexes in the mongodb for the content model" - def handle(self, *args: list[str], **kwargs: dict[str, str]) -> None: + def handle(self, *_args: list[str], **_kwargs: dict[str, str]) -> None: """ Handles the execution of the forum_create_mongodb_indexes command. diff --git a/forum/management/commands/forum_create_mute_mongodb_indexes.py b/forum/management/commands/forum_create_mute_mongodb_indexes.py new file mode 100644 index 00000000..0d0bfda8 --- /dev/null +++ b/forum/management/commands/forum_create_mute_mongodb_indexes.py @@ -0,0 +1,297 @@ +""" +Management command to create MongoDB indexes for discussion mute functionality. +""" + +from typing import Any + +import pymongo +from django.core.management.base import BaseCommand +from pymongo import MongoClient +from pymongo.errors import OperationFailure + +from forum.backends.mongodb.mutes import ( + DiscussionModerationLogs, + DiscussionMuteExceptions, + DiscussionMutes, +) + + +class Command(BaseCommand): + """ + Creates MongoDB indexes for optimal mute query performance. + + Usage: python manage.py forum_create_mute_mongodb_indexes + """ + + help = "Create MongoDB indexes for discussion mute functionality" + + def add_arguments(self, parser: Any) -> None: + """ + Add command-line arguments for the forum_create_mute_mongodb_indexes command. + """ + parser.add_argument( + "--drop-existing", + action="store_true", + dest="drop_existing", + help="Drop existing indexes before creating new ones", + ) + parser.add_argument( + "--database-url", + type=str, + default="mongodb://localhost:27017/", + help="MongoDB connection URL", + ) + parser.add_argument( + "--database-name", + type=str, + default="cs_comments_service", + help="MongoDB database name", + ) + + def handle(self, *_args: Any, **options: Any) -> None: + """Create the indexes.""" + database_url = options["database_url"] + database_name = options["database_name"] + drop_existing = options["drop_existing"] + + self.stdout.write("Creating MongoDB indexes for mute functionality...") + + try: + # Connect to MongoDB + client: MongoClient[Any] = MongoClient(database_url) + db = client[database_name] + + # Create indexes for each collection + self._create_mute_indexes(db, drop_existing) + self._create_exception_indexes(db, drop_existing) + self._create_log_indexes(db, drop_existing) + + client.close() + + self.stdout.write( + self.style.SUCCESS("Successfully created MongoDB mute indexes!") + ) + + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error creating indexes: {e}")) + raise + + def _create_mute_indexes(self, db: Any, drop_existing: bool) -> None: + """Create indexes for discussion_mutes collection.""" + collection_name = DiscussionMutes.COLLECTION_NAME + collection = db[collection_name] + + self.stdout.write(f"Creating indexes for {collection_name}...") + + if drop_existing: + collection.drop_indexes() + self.stdout.write(" - Dropped existing indexes") + + # Index for finding active mutes by user and course + try: + collection.create_index( + [ + ("muted_user_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ("is_active", pymongo.ASCENDING), + ], + name="muted_user_course_active", + ) + self.stdout.write(" ✓ Created muted_user_course_active index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for personal mutes (includes muted_by_id) + try: + collection.create_index( + [ + ("muted_user_id", pymongo.ASCENDING), + ("muted_by_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ("scope", pymongo.ASCENDING), + ("is_active", pymongo.ASCENDING), + ], + name="personal_mute_lookup", + ) + self.stdout.write(" ✓ Created personal_mute_lookup index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for course-wide mutes + try: + collection.create_index( + [ + ("course_id", pymongo.ASCENDING), + ("scope", pymongo.ASCENDING), + ("is_active", pymongo.ASCENDING), + ], + name="course_mute_lookup", + ) + self.stdout.write(" ✓ Created course_mute_lookup index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding mutes by moderator + try: + collection.create_index( + [ + ("muted_by_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ("created_at", pymongo.DESCENDING), + ], + name="moderator_activity", + ) + self.stdout.write(" ✓ Created moderator_activity index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Compound index for preventing duplicate active mutes + try: + collection.create_index( + [ + ("muted_user_id", pymongo.ASCENDING), + ("muted_by_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ("scope", pymongo.ASCENDING), + ], + partialFilterExpression={"is_active": True}, + name="prevent_duplicate_active_mutes", + ) + self.stdout.write(" ✓ Created prevent_duplicate_active_mutes index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + def _create_exception_indexes(self, db: Any, drop_existing: bool) -> None: + """Create indexes for discussion_mute_exceptions collection.""" + collection_name = DiscussionMuteExceptions.COLLECTION_NAME + collection = db[collection_name] + + self.stdout.write(f"Creating indexes for {collection_name}...") + + if drop_existing: + collection.drop_indexes() + self.stdout.write(" - Dropped existing indexes") + + # Unique compound index for exceptions + try: + collection.create_index( + [ + ("muted_user_id", pymongo.ASCENDING), + ("exception_user_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ], + unique=True, + name="unique_exception", + ) + self.stdout.write(" ✓ Created unique_exception index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding exceptions by course + try: + collection.create_index( + [("course_id", pymongo.ASCENDING), ("created_at", pymongo.DESCENDING)], + name="course_exceptions", + ) + self.stdout.write(" ✓ Created course_exceptions index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding exceptions by muted user + try: + collection.create_index( + [ + ("muted_user_id", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ], + name="muted_user_exceptions", + ) + self.stdout.write(" ✓ Created muted_user_exceptions index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + def _create_log_indexes(self, db: Any, drop_existing: bool) -> None: + """Create indexes for discussion_moderation_logs collection.""" + collection_name = DiscussionModerationLogs.COLLECTION_NAME + collection = db[collection_name] + + self.stdout.write(f"Creating indexes for {collection_name}...") + + if drop_existing: + collection.drop_indexes() + self.stdout.write(" - Dropped existing indexes") + + # Index for finding logs by target user + try: + collection.create_index( + [ + ("target_user_id", pymongo.ASCENDING), + ("timestamp", pymongo.DESCENDING), + ], + name="user_logs", + ) + self.stdout.write(" ✓ Created user_logs index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding logs by course + try: + collection.create_index( + [("course_id", pymongo.ASCENDING), ("timestamp", pymongo.DESCENDING)], + name="course_logs", + ) + self.stdout.write(" ✓ Created course_logs index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding logs by moderator + try: + collection.create_index( + [ + ("moderator_id", pymongo.ASCENDING), + ("timestamp", pymongo.DESCENDING), + ], + name="moderator_logs", + ) + self.stdout.write(" ✓ Created moderator_logs index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # Index for finding logs by action type + try: + collection.create_index( + [ + ("action_type", pymongo.ASCENDING), + ("course_id", pymongo.ASCENDING), + ("timestamp", pymongo.DESCENDING), + ], + name="action_type_logs", + ) + self.stdout.write(" ✓ Created action_type_logs index") + except OperationFailure as e: + if "already exists" not in str(e): + raise + + # TTL index for automatic log cleanup (optional) + try: + # Logs older than 1 year will be automatically deleted + collection.create_index( + [("timestamp", pymongo.ASCENDING)], + expireAfterSeconds=31536000, + name="log_ttl", + ) # 365 days * 24 hours * 60 minutes * 60 seconds + self.stdout.write(" ✓ Created log_ttl index (1 year TTL)") + except OperationFailure as e: + if "already exists" not in str(e): + raise diff --git a/forum/management/commands/forum_delete_course_from_mongodb.py b/forum/management/commands/forum_delete_course_from_mongodb.py index dfd0098c..8790722f 100644 --- a/forum/management/commands/forum_delete_course_from_mongodb.py +++ b/forum/management/commands/forum_delete_course_from_mongodb.py @@ -23,7 +23,7 @@ def add_arguments(self, parser: CommandParser) -> None: help="Perform a dry run without actually deleting data", ) - def handle(self, *args: str, **options: dict[str, Any]) -> None: + def handle(self, *_args: str, **options: dict[str, Any]) -> None: """Handle method for command.""" db = get_database() diff --git a/forum/management/commands/forum_migrate_course_from_mongodb_to_mysql.py b/forum/management/commands/forum_migrate_course_from_mongodb_to_mysql.py index 298d47a4..9ca1abf3 100644 --- a/forum/management/commands/forum_migrate_course_from_mongodb_to_mysql.py +++ b/forum/management/commands/forum_migrate_course_from_mongodb_to_mysql.py @@ -2,8 +2,7 @@ from typing import Any -from django.core.management.base import BaseCommand -from django.core.management.base import CommandParser +from django.core.management.base import BaseCommand, CommandParser from forum.migration_helpers import ( enable_mysql_backend_for_course, @@ -32,7 +31,7 @@ def add_arguments(self, parser: CommandParser) -> None: "courses", nargs="+", type=str, help="List of course IDs or `all`" ) - def handle(self, *args: str, **options: dict[str, Any]) -> None: + def handle(self, *_args: str, **options: dict[str, Any]) -> None: """Handle the command.""" db = get_database() diff --git a/forum/management/commands/initialize_forum_indices.py b/forum/management/commands/initialize_forum_indices.py index f4103851..59c3354a 100644 --- a/forum/management/commands/initialize_forum_indices.py +++ b/forum/management/commands/initialize_forum_indices.py @@ -28,7 +28,7 @@ def add_arguments(self, parser: ArgumentParser) -> None: help="Force the creation of new indices even if they exist.", ) - def handle(self, *args: list[str], **kwargs: dict[str, Any]) -> None: + def handle(self, *_args: list[str], **kwargs: dict[str, Any]) -> None: """ Handles the execution of the initialize_indices command. diff --git a/forum/management/commands/rebuild_forum_indices.py b/forum/management/commands/rebuild_forum_indices.py index 427050f7..c659e87f 100644 --- a/forum/management/commands/rebuild_forum_indices.py +++ b/forum/management/commands/rebuild_forum_indices.py @@ -35,7 +35,7 @@ def add_arguments(self, parser: ArgumentParser) -> None: help="Extra minutes to adjust the start time for catch-up.", ) - def handle(self, *args: list[str], **kwargs: dict[str, int]) -> None: + def handle(self, *_args: list[str], **kwargs: dict[str, int]) -> None: """ Handles the execution of the rebuild_indices command. diff --git a/forum/management/commands/validate_forum_indices.py b/forum/management/commands/validate_forum_indices.py index e247d051..dccdbcdd 100644 --- a/forum/management/commands/validate_forum_indices.py +++ b/forum/management/commands/validate_forum_indices.py @@ -12,7 +12,7 @@ class Command(BaseCommand): help = "Validate Elasticsearch indices for correct mappings and properties." - def handle(self, *args: list[str], **kwargs: dict[str, str]) -> None: + def handle(self, *_args: list[str], **_kwargs: dict[str, str]) -> None: """ Handles the execution of the validate_forum_indices command. diff --git a/forum/migration_helpers.py b/forum/migration_helpers.py index 86a85db3..0fe7b6df 100644 --- a/forum/migration_helpers.py +++ b/forum/migration_helpers.py @@ -1,7 +1,7 @@ """Migration commands helper methods.""" -from typing import Any import logging +from typing import Any from django.contrib.auth.models import User # pylint: disable=E5142 from django.core.management.base import OutputWrapper @@ -23,8 +23,7 @@ Subscription, UserVote, ) -from forum.utils import make_aware, get_trunc_title - +from forum.utils import get_trunc_title, make_aware logger = logging.getLogger(__name__) @@ -462,7 +461,6 @@ def log_deletion( def enable_mysql_backend_for_course(course_id: str) -> None: """Enable MySQL backend waffle flag for a course.""" from opaque_keys.edx.keys import CourseKey - from openedx.core.djangoapps.waffle_utils.models import ( # type: ignore[import-not-found] WaffleFlagCourseOverrideModel, ) diff --git a/forum/migrations/0006_add_discussion_mute_models.py b/forum/migrations/0006_add_discussion_mute_models.py new file mode 100644 index 00000000..31dde99c --- /dev/null +++ b/forum/migrations/0006_add_discussion_mute_models.py @@ -0,0 +1,200 @@ +# Generated on 2025-12-16 for mute functionality + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ("forum", "0005_moderationauditlog_comment_is_spam_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DiscussionMute", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "course_id", + models.CharField( + db_index=True, + help_text="Course in which mute applies", + max_length=255, + ), + ), + ( + "scope", + models.CharField( + choices=[("personal", "Personal"), ("course", "Course-wide")], + db_index=True, + default="personal", + help_text="Scope of the mute (personal or course-wide)", + max_length=10, + ), + ), + ( + "reason", + models.TextField( + blank=True, help_text="Optional reason for muting" + ), + ), + ( + "is_active", + models.BooleanField( + default=True, help_text="Whether the mute is currently active" + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("muted_at", models.DateTimeField(auto_now_add=True)), + ("unmuted_at", models.DateTimeField(blank=True, null=True)), + ( + "muted_by", + models.ForeignKey( + db_index=True, + help_text="User performing the mute", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_muted_users", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "muted_user", + models.ForeignKey( + db_index=True, + help_text="User being muted", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_muted_by_users", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "unmuted_by", + models.ForeignKey( + blank=True, + help_text="User who performed the unmute action", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="forum_mute_unactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "forum_discussion_user_mute", + }, + ), + migrations.CreateModel( + name="DiscussionMuteException", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "course_id", + models.CharField( + db_index=True, + help_text="Course where the exception applies", + max_length=255, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "exception_user", + models.ForeignKey( + db_index=True, + help_text="User who unmuted the muted_user for themselves", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_mute_exceptions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "muted_user", + models.ForeignKey( + db_index=True, + help_text="User who is globally muted in this course", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_mute_exceptions_for", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "forum_discussion_mute_exception", + }, + ), + migrations.AddConstraint( + model_name="discussionmute", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True), ("scope", "personal")), + fields=("muted_user", "muted_by", "course_id", "scope"), + name="forum_unique_active_personal_mute", + ), + ), + migrations.AddConstraint( + model_name="discussionmute", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True), ("scope", "course")), + fields=("muted_user", "course_id"), + name="forum_unique_active_course_mute", + ), + ), + migrations.AddIndex( + model_name="discussionmute", + index=models.Index( + fields=["muted_user", "course_id", "is_active"], + name="forum_discussion_user_mute_muted_user_course_id_is_active_idx", + ), + ), + migrations.AddIndex( + model_name="discussionmute", + index=models.Index( + fields=["muted_by", "course_id", "scope"], + name="forum_discussion_user_mute_muted_by_course_id_scope_idx", + ), + ), + migrations.AddIndex( + model_name="discussionmute", + index=models.Index( + fields=["scope", "course_id", "is_active"], + name="forum_discussion_user_mute_scope_course_id_is_active_idx", + ), + ), + migrations.AlterUniqueTogether( + name="discussionmuteexception", + unique_together={("muted_user", "exception_user", "course_id")}, + ), + migrations.AddIndex( + model_name="discussionmuteexception", + index=models.Index( + fields=["muted_user", "course_id"], + name="forum_discussion_mute_exception_muted_user_course_id_idx", + ), + ), + migrations.AddIndex( + model_name="discussionmuteexception", + index=models.Index( + fields=["exception_user", "course_id"], + name="forum_discussion_mute_exception_exception_user_course_id_idx", + ), + ), + ] diff --git a/forum/serializers/mute.py b/forum/serializers/mute.py new file mode 100644 index 00000000..03d93c66 --- /dev/null +++ b/forum/serializers/mute.py @@ -0,0 +1,274 @@ +""" +Forum Mute/Unmute Serializers. +""" + +from typing import Any, Dict + +from rest_framework import serializers + +from forum.models import DiscussionMute, DiscussionMuteException, ModerationAuditLog + + +class MuteInputSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for mute input data.""" + + muter_id = serializers.CharField( + required=True, help_text="ID of user performing the mute action" + ) + scope = serializers.ChoiceField( + choices=DiscussionMute.Scope.choices, + default=DiscussionMute.Scope.PERSONAL, + help_text="Scope of the mute (personal or course-wide)", + ) + reason = serializers.CharField( + required=False, allow_blank=True, help_text="Optional reason for muting" + ) + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support create operations") + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support update operations") + + +class UnmuteInputSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for unmute input data.""" + + unmuter_id = serializers.CharField( + required=True, help_text="ID of user performing the unmute action" + ) + scope = serializers.ChoiceField( + choices=DiscussionMute.Scope.choices, + default=DiscussionMute.Scope.PERSONAL, + help_text="Scope of the unmute (personal or course-wide)", + ) + muted_by_id = serializers.CharField( + required=False, + allow_blank=True, + help_text="Original muter ID (for personal scope unmutes)", + ) + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support create operations") + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support update operations") + + +class MuteAndReportInputSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for mute and report input data.""" + + muter_id = serializers.CharField( + required=True, help_text="ID of user performing the mute and report action" + ) + scope = serializers.ChoiceField( + choices=DiscussionMute.Scope.choices, + default=DiscussionMute.Scope.PERSONAL, + help_text="Scope of the mute (personal or course-wide)", + ) + reason = serializers.CharField( + required=True, + help_text="Reason for muting and reporting (required for reports)", + ) + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support create operations") + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for input serializers.""" + raise NotImplementedError("Input serializers do not support update operations") + + +class UserMuteStatusSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for user mute status response.""" + + user_id = serializers.CharField(help_text="ID of the user being checked") + course_id = serializers.CharField(help_text="Course ID") + is_muted = serializers.BooleanField(help_text="Whether the user is muted") + mute_scope = serializers.CharField( + allow_null=True, + help_text="Scope of active mute (personal/course/null if not muted)", + ) + muted_by_id = serializers.CharField( + allow_null=True, help_text="ID of user who muted this user (for personal mutes)" + ) + muted_by_username = serializers.CharField( + allow_null=True, help_text="Username of user who muted this user" + ) + muted_at = serializers.DateTimeField( + allow_null=True, help_text="When the user was muted" + ) + reason = serializers.CharField(allow_null=True, help_text="Reason for muting") + has_exception = serializers.BooleanField( + default=False, help_text="Whether viewer has an exception for course-wide mutes" + ) + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support create operations" + ) + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support update operations" + ) + + +class MutedUserSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for a muted user entry.""" + + user_id = serializers.CharField(help_text="ID of the muted user") + username = serializers.CharField(help_text="Username of the muted user") + muted_by_id = serializers.CharField(help_text="ID of user who performed the mute") + muted_by_username = serializers.CharField( + help_text="Username of user who performed the mute" + ) + scope = serializers.CharField(help_text="Mute scope (personal or course)") + reason = serializers.CharField(help_text="Reason for muting") + muted_at = serializers.DateTimeField(help_text="When the user was muted") + is_active = serializers.BooleanField( + help_text="Whether the mute is currently active" + ) + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support create operations" + ) + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support update operations" + ) + + +class CourseMutedUsersSerializer(serializers.Serializer[Dict[str, Any]]): + """Serializer for course-wide muted users list response.""" + + course_id = serializers.CharField(help_text="Course ID") + requester_id = serializers.CharField( + allow_null=True, help_text="ID of user requesting the list" + ) + scope_filter = serializers.CharField(help_text="Applied scope filter") + total_count = serializers.IntegerField(help_text="Total number of muted users") + muted_users = MutedUserSerializer(many=True, help_text="List of muted users") + + def create(self, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support create operations" + ) + + def update(self, instance: Any, validated_data: Dict[str, Any]) -> Any: + """Not used for response serializers.""" + raise NotImplementedError( + "Response serializers do not support update operations" + ) + + +class DiscussionMuteSerializer(serializers.ModelSerializer[DiscussionMute]): + """Serializer for DiscussionMute model.""" + + muted_user_id = serializers.CharField(source="muted_user.pk", read_only=True) + muted_user_username = serializers.CharField( + source="muted_user.username", read_only=True + ) + muted_by_id = serializers.CharField(source="muted_by.pk", read_only=True) + muted_by_username = serializers.CharField( + source="muted_by.username", read_only=True + ) + unmuted_by_id = serializers.CharField( + source="unmuted_by.pk", read_only=True, allow_null=True + ) + unmuted_by_username = serializers.CharField( + source="unmuted_by.username", read_only=True, allow_null=True + ) + + class Meta: + model = DiscussionMute + fields = [ + "id", + "muted_user_id", + "muted_user_username", + "muted_by_id", + "muted_by_username", + "unmuted_by_id", + "unmuted_by_username", + "course_id", + "scope", + "reason", + "is_active", + "created", + "modified", + "muted_at", + "unmuted_at", + ] + read_only_fields = ["id", "created", "modified", "muted_at", "unmuted_at"] + + +class DiscussionMuteExceptionSerializer( + serializers.ModelSerializer[DiscussionMuteException] +): + """Serializer for DiscussionMuteException model.""" + + muted_user_id = serializers.CharField(source="muted_user.pk", read_only=True) + muted_user_username = serializers.CharField( + source="muted_user.username", read_only=True + ) + exception_user_id = serializers.CharField( + source="exception_user.pk", read_only=True + ) + exception_user_username = serializers.CharField( + source="exception_user.username", read_only=True + ) + + class Meta: + model = DiscussionMuteException + fields = [ + "id", + "muted_user_id", + "muted_user_username", + "exception_user_id", + "exception_user_username", + "course_id", + "created", + "modified", + ] + read_only_fields = ["id", "created", "modified"] + + +class ModerationAuditLogSerializer(serializers.ModelSerializer[ModerationAuditLog]): + """Serializer for ModerationAuditLog model (mute-related entries).""" + + moderator_id = serializers.CharField( + source="moderator.pk", read_only=True, allow_null=True + ) + moderator_username = serializers.CharField( + source="moderator.username", read_only=True, allow_null=True + ) + + class Meta: + model = ModerationAuditLog + fields = [ + "id", + "timestamp", + "body", + "classifier_output", + "reasoning", + "classification", + "actions_taken", + "confidence_score", + "moderator_override", + "override_reason", + "moderator_id", + "moderator_username", + ] + read_only_fields = ["id", "timestamp"] diff --git a/forum/views/commentables.py b/forum/views/commentables.py index 793e331b..cf5dca1a 100644 --- a/forum/views/commentables.py +++ b/forum/views/commentables.py @@ -16,7 +16,7 @@ class CommentablesCountAPIView(APIView): permission_classes = (AllowAny,) - def get(self, request: Request, course_id: str) -> Response: + def get(self, _request: Request, course_id: str) -> Response: """ Retrieves a the threads count based on thread_type. diff --git a/forum/views/comments.py b/forum/views/comments.py index ed90507c..da725183 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -8,8 +8,8 @@ from rest_framework.views import APIView from forum.api import ( - create_parent_comment, create_child_comment, + create_parent_comment, delete_comment, get_parent_comment, update_comment, @@ -24,7 +24,7 @@ class CommentsAPIView(APIView): permission_classes = (AllowAny,) - def get(self, request: Request, comment_id: str) -> Response: + def get(self, _request: Request, comment_id: str) -> Response: """ Retrieves a parent comment. For chile comments, below API is called that return all child comments in children field @@ -134,7 +134,7 @@ def put(self, request: Request, comment_id: str) -> Response: ) return Response(comment, status=status.HTTP_200_OK) - def delete(self, request: Request, comment_id: str) -> Response: + def delete(self, _request: Request, comment_id: str) -> Response: """ Deletes a comment. diff --git a/forum/views/mutes.py b/forum/views/mutes.py new file mode 100644 index 00000000..2920faab --- /dev/null +++ b/forum/views/mutes.py @@ -0,0 +1,291 @@ +""" +Forum Mute / Unmute API Views. +""" + +import logging + +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from forum.api.mutes import ( + get_all_muted_users_for_course, + get_user_mute_status, + mute_and_report_user, + mute_user, + unmute_user, +) +from forum.utils import ForumV2RequestError + +log = logging.getLogger(__name__) + + +class MuteUserAPIView(APIView): + """ + API View for muting users in discussions. + + Handles POST requests to mute a user. + """ + + permission_classes = (AllowAny,) + + def post(self, request: Request, user_id: str, course_id: str) -> Response: + """ + Mute a user in discussions. + + Parameters: + request (Request): The incoming request. + user_id (str): The ID of the user to mute. + course_id (str): The course ID. + + Body: + muter_id: ID of user performing the mute + scope: Mute scope ('personal' or 'course') + reason: Optional reason for muting + + Returns: + Response: A response with the mute operation result. + """ + try: + muter_id = request.data.get("muter_id") + scope = request.data.get("scope", "personal") + reason = request.data.get("reason", "") + + if not muter_id: + return Response( + {"error": "muter_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = mute_user( + muted_user_id=user_id, + muted_by_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + return Response(result, status=status.HTTP_200_OK) + + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Unexpected error in mute_user: {str(e)}") + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UnmuteUserAPIView(APIView): + """ + API View for unmuting users in discussions. + + Handles POST requests to unmute a user. + """ + + permission_classes = (AllowAny,) + + def post(self, request: Request, user_id: str, course_id: str) -> Response: + """ + Unmute a user in discussions. + + Parameters: + request (Request): The incoming request. + user_id (str): The ID of the user to unmute. + course_id (str): The course ID. + + Body: + muter_id: ID of user performing the unmute + scope: Unmute scope ('personal' or 'course') + muted_by_id: Optional - for personal scope unmutes + + Returns: + Response: A response with the unmute operation result. + """ + try: + muter_id = request.data.get("muter_id") + scope = request.data.get("scope", "personal") + muted_by_id = request.data.get("muted_by_id") + + if not muter_id: + return Response( + {"error": "moderator_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = unmute_user( + muted_user_id=user_id, + unmuted_by_id=muter_id, + course_id=course_id, + scope=scope, + muted_by_id=muted_by_id, + ) + + return Response(result, status=status.HTTP_200_OK) + + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Unexpected error in unmute_user: {str(e)}") + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class MuteAndReportUserAPIView(APIView): + """ + API View for muting and reporting users in discussions. + + Handles POST requests to mute and report a user. + """ + + permission_classes = (AllowAny,) + + def post(self, request: Request, user_id: str, course_id: str) -> Response: + """ + Mute and report a user in discussions. + + Parameters: + request (Request): The incoming request. + user_id (str): The ID of the user to mute and report. + course_id (str): The course ID. + + Body: + muter_id: ID of user performing the action + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Response: A response with the mute and report operation result. + """ + try: + muter_id = request.data.get("muter_id") + scope = request.data.get("scope", "personal") + reason = request.data.get("reason", "") + + if not muter_id: + return Response( + {"error": "muter_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = mute_and_report_user( + muted_user_id=user_id, + muted_by_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + return Response(result, status=status.HTTP_200_OK) + + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Unexpected error in mute_and_report_user: {str(e)}") + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UserMuteStatusAPIView(APIView): + """ + API View for getting user mute status. + + Handles GET requests to check if a user is muted. + """ + + permission_classes = (AllowAny,) + + def get(self, request: Request, user_id: str, course_id: str) -> Response: + """ + Get mute status for a user. + + Parameters: + request (Request): The incoming request. + user_id (str): The ID of the user to check. + course_id (str): The course ID. + + Query Parameters: + viewer_id: ID of the user checking the status + + Returns: + Response: A response with the user's mute status. + """ + try: + viewer_id = request.query_params.get("viewer_id") + + if not viewer_id: + return Response( + {"error": "viewer_id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = get_user_mute_status( + user_id=user_id, + course_id=course_id, + viewer_id=viewer_id, + ) + + return Response(result, status=status.HTTP_200_OK) + + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Unexpected error in get_user_mute_status: {str(e)}") + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class CourseMutedUsersAPIView(APIView): + """ + API View for getting all muted users in a course. + + Handles GET requests to get course-wide muted users list. + """ + + permission_classes = (AllowAny,) + + def get(self, request: Request, course_id: str) -> Response: + """ + Get all muted users in a course. + + Parameters: + request (Request): The incoming request. + course_id (str): The course ID. + + Query Parameters: + muter_id: ID of user requesting the list + scope: Filter by scope ('personal', 'course', or 'all') + requester_id: Optional ID of requesting user + + Returns: + Response: A response with the course muted users list. + """ + try: + requester_id = request.query_params.get("requester_id") + scope = request.query_params.get("scope", "all") + + result = get_all_muted_users_for_course( + course_id=course_id, + requester_id=requester_id, + scope=scope, + ) + + return Response(result, status=status.HTTP_200_OK) + + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Unexpected error in get_all_muted_users_for_course: {str(e)}") + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/forum/views/threads.py b/forum/views/threads.py index 893bd2a3..37d0bde2 100644 --- a/forum/views/threads.py +++ b/forum/views/threads.py @@ -52,7 +52,7 @@ def get(self, request: Request, thread_id: str) -> Response: ) return Response(data, status=status.HTTP_200_OK) - def delete(self, request: Request, thread_id: str) -> Response: + def delete(self, _request: Request, thread_id: str) -> Response: """ Deletes a thread by its ID. diff --git a/forum/views/users.py b/forum/views/users.py index 1a8f94a4..425cd01e 100644 --- a/forum/views/users.py +++ b/forum/views/users.py @@ -222,7 +222,7 @@ def get(self, request: Request, course_id: str) -> Response: ) return Response(response, status=status.HTTP_200_OK) - def post(self, request: Request, course_id: str) -> Response: + def post(self, _request: Request, course_id: str) -> Response: """Update user stats for a course.""" updated_users = update_users_in_course(course_id) return Response(updated_users, status=status.HTTP_200_OK) diff --git a/test_utils/mock_es_backend.py b/test_utils/mock_es_backend.py index 781d50f3..9506b252 100644 --- a/test_utils/mock_es_backend.py +++ b/test_utils/mock_es_backend.py @@ -3,7 +3,8 @@ """ from typing import Any -from forum.search.es import ElasticsearchIndexBackend, ElasticsearchDocumentBackend + +from forum.search.es import ElasticsearchDocumentBackend, ElasticsearchIndexBackend class MockElasticsearchIndexBackend(ElasticsearchIndexBackend): diff --git a/tests/conftest.py b/tests/conftest.py index b7ecc1e4..c950a8cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,12 +10,12 @@ from pymongo import MongoClient from pymongo.database import Database -from forum.backends.mysql.api import MySQLBackend from forum.backends.mongodb.api import MongoBackend +from forum.backends.mysql.api import MySQLBackend from test_utils.client import APIClient from test_utils.mock_es_backend import ( - MockElasticsearchIndexBackend, MockElasticsearchDocumentBackend, + MockElasticsearchIndexBackend, ) diff --git a/tests/e2e/test_search_meilisearch.py b/tests/e2e/test_search_meilisearch.py index a1cfc4a3..b61a3c3b 100644 --- a/tests/e2e/test_search_meilisearch.py +++ b/tests/e2e/test_search_meilisearch.py @@ -4,8 +4,8 @@ import typing as t -from django.test import override_settings import pytest +from django.test import override_settings import forum.search.meilisearch diff --git a/tests/test_management/test_commands/test_migration_commands.py b/tests/test_management/test_commands/test_migration_commands.py index 06965897..c703dfa6 100644 --- a/tests/test_management/test_commands/test_migration_commands.py +++ b/tests/test_management/test_commands/test_migration_commands.py @@ -5,8 +5,8 @@ import pytest from bson import ObjectId -from django.core.management import call_command from django.contrib.auth.models import User # pylint: disable=E5142 +from django.core.management import call_command from django.utils import timezone from pymongo.database import Database @@ -23,7 +23,6 @@ ) from forum.utils import get_trunc_title - pytestmark = pytest.mark.django_db diff --git a/tests/test_meilisearch.py b/tests/test_meilisearch.py index 20672e90..3c98714e 100644 --- a/tests/test_meilisearch.py +++ b/tests/test_meilisearch.py @@ -2,9 +2,10 @@ Unit tests for the meilisearch search backend. """ -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import search.meilisearch as m + from forum.search import meilisearch TEST_ID = "abcd" diff --git a/tests/test_views/test_commentables.py b/tests/test_views/test_commentables.py index 2d650715..e26a6f40 100644 --- a/tests/test_views/test_commentables.py +++ b/tests/test_views/test_commentables.py @@ -1,9 +1,8 @@ """Test commentables count api endpoint.""" -from typing import Any - import random import uuid +from typing import Any import pytest diff --git a/tests/test_views/test_comments.py b/tests/test_views/test_comments.py index 799a6145..6477a7ab 100644 --- a/tests/test_views/test_comments.py +++ b/tests/test_views/test_comments.py @@ -1,6 +1,7 @@ """Test comments api endpoints.""" from typing import Any + import pytest from test_utils.client import APIClient diff --git a/tests/test_views/test_flags.py b/tests/test_views/test_flags.py index a1965026..5a9e9709 100644 --- a/tests/test_views/test_flags.py +++ b/tests/test_views/test_flags.py @@ -1,6 +1,7 @@ """Test flags api endpoints.""" from typing import Any + import pytest from test_utils.client import APIClient diff --git a/tests/test_views/test_pins.py b/tests/test_views/test_pins.py index fc9bdecc..1d6171c1 100644 --- a/tests/test_views/test_pins.py +++ b/tests/test_views/test_pins.py @@ -1,6 +1,7 @@ """Test pin/unpin thread api endpoints.""" from typing import Any + import pytest from test_utils.client import APIClient diff --git a/tests/test_views/test_subscriptions.py b/tests/test_views/test_subscriptions.py index 4ea58279..7380db6d 100644 --- a/tests/test_views/test_subscriptions.py +++ b/tests/test_views/test_subscriptions.py @@ -1,6 +1,7 @@ """Tests for subscription apis.""" from typing import Any + import pytest from test_utils.client import APIClient diff --git a/tests/test_views/test_threads.py b/tests/test_views/test_threads.py index ca28d864..f7c2fc3f 100644 --- a/tests/test_views/test_threads.py +++ b/tests/test_views/test_threads.py @@ -3,6 +3,7 @@ import time from datetime import datetime from typing import Any, Optional + import pytest from forum.backends.mongodb.api import MongoBackend diff --git a/tests/test_views/test_users.py b/tests/test_views/test_users.py index 732e7216..8a8d9db2 100644 --- a/tests/test_views/test_users.py +++ b/tests/test_views/test_users.py @@ -1,6 +1,7 @@ """Tests for Users apis.""" from typing import Any + import pytest from forum.constants import RETIRED_BODY, RETIRED_TITLE