From 5f2730b72b606a90beb2e17b12202ed5ce1432cc Mon Sep 17 00:00:00 2001 From: naincy128 Date: Tue, 16 Dec 2025 09:39:09 +0000 Subject: [PATCH] feat: implement mute/unmute feature --- forum/admin.py | 19 + forum/api/__init__.py | 14 + forum/api/mutes.py | 233 +++++++++ forum/backends/backend.py | 262 ++++++++++ forum/backends/mongodb/api.py | 239 +++++++++ forum/backends/mongodb/mutes.py | 477 ++++++++++++++++++ forum/backends/mysql/api.py | 274 +++++++++- forum/backends/mysql/models.py | 221 +++++++- .../forum_create_mute_mongodb_indexes.py | 262 ++++++++++ .../0006_add_discussion_mute_models.py | 200 ++++++++ forum/serializers/mute.py | 182 +++++++ forum/views/mutes.py | 303 +++++++++++ 12 files changed, 2683 insertions(+), 3 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..7ce4ffb3 100644 --- a/forum/admin.py +++ b/forum/admin.py @@ -14,6 +14,8 @@ UserVote, Subscription, MongoContent, + DiscussionMute, + DiscussionMuteException, ModerationAuditLog, ) @@ -148,6 +150,23 @@ class SubscriptionAdmin(admin.ModelAdmin): # type: ignore search_fields = ("subscriber__username",) 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 diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..46a7d522 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -16,6 +16,14 @@ update_comment_flag, update_thread_flag, ) +from .mutes import ( + get_muted_users, + get_user_mute_status, + mute_user, + unmute_user, + mute_and_report_user, + get_all_muted_users_for_course, +) from .pins import pin_thread, unpin_thread from .search import search_threads from .subscriptions import ( @@ -87,4 +95,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..38e4d4f8 --- /dev/null +++ b/forum/api/mutes.py @@ -0,0 +1,233 @@ +""" +Native Python APIs for discussion moderation (mute/unmute). +""" + +from datetime import datetime +from typing import Any, Dict, List, 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 +) -> 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: + Dictionary containing 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 \ No newline at end of file diff --git a/forum/backends/backend.py b/forum/backends/backend.py index 8a5b9175..04529a47 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -476,3 +476,265 @@ 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_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: Original muter ID (for personal unmutes) + + Returns: + Dictionary containing unmute result + """ + 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_user_mute_status( + cls, + user_id: str, + course_id: str, + viewer_id: str, + **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 + """ + 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..4e33a3c6 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -15,6 +15,11 @@ Subscriptions, Users, ) +from forum.backends.mongodb.mutes import ( + DiscussionMutes, + DiscussionMuteExceptions, + DiscussionModerationLogs +) from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -1811,3 +1816,237 @@ 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, + user_id: str, + course_id: str, + viewer_id: str, + **kwargs: Any + ) -> dict[str, Any]: + """ + Get mute status for a user using MongoDB backend. + + 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: + mutes = DiscussionMutes() + return mutes.get_user_mute_status( + user_id=user_id, + course_id=course_id, + viewer_id=viewer_id + ) + + 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 diff --git a/forum/backends/mongodb/mutes.py b/forum/backends/mongodb/mutes.py new file mode 100644 index 00000000..1a86fbd9 --- /dev/null +++ b/forum/backends/mongodb/mutes.py @@ -0,0 +1,477 @@ +"""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 + """ + 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 + + return list(self._collection.find(query)) + + 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 + """ + 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" + + return list(self._collection.find(query)) + + 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 + """ + return list(self._collection.find({"course_id": course_id})) + + +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 + """ + query = {"target_user_id": user_id} + if course_id: + query["course_id"] = course_id + + return list( + self._collection.find(query) + .sort("timestamp", -1) + .limit(limit) + ) + + 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 + """ + query = {"course_id": course_id} + if action_type: + query["action_type"] = action_type + + return list( + self._collection.find(query) + .sort("timestamp", -1) + .limit(limit) + ) \ No newline at end of file diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index c8633476..1d9269fb 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -3,7 +3,7 @@ import math import random from datetime import timedelta -from typing import Any, Optional, Union +from typing import Any, Optional, Union, Dict from django.contrib.auth.models import User # pylint: disable=E5142 from django.contrib.contenttypes.models import ContentType @@ -32,6 +32,8 @@ Comment, CommentThread, CourseStat, + DiscussionMute, + DiscussionMuteException, EditHistory, ForumUser, HistoricalAbuseFlagger, @@ -2255,3 +2257,273 @@ 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}") + except Exception as e: + raise ValueError(f"Failed to mute user: {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}") + except Exception as e: + raise ValueError(f"Failed to unmute user: {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, + user_id: str, + course_id: str, + viewer_id: str, + **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(user_id)) + viewer = User.objects.get(pk=int(viewer_id)) + + # 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": 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}") + except Exception as e: + raise ValueError(f"Failed to get mute status: {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}") diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index e149daa6..c68aa2af 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -11,6 +11,7 @@ from django.db import models from django.db.models import QuerySet from django.utils import timezone +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from forum.utils import validate_upvote_or_downvote @@ -796,6 +797,11 @@ 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 @@ -813,8 +819,9 @@ class ModerationAuditLog(models.Model): classifier_output: models.JSONField[dict[str, Any], dict[str, Any]] = ( models.JSONField(help_text="Full output from the AI classifier") ) - reasoning: models.TextField[str, str] = models.TextField( - help_text="AI reasoning for the decision" + reasoning = models.TextField( + blank=True, + help_text="AI reasoning for the decision", ) classification: models.CharField[str, str] = models.CharField( max_length=20, @@ -849,6 +856,28 @@ class ModerationAuditLog(models.Model): help_text="Original author of the moderated content", ) + # ---- ADDED: fields required for mute moderation ---- + course_id = models.CharField( + max_length=255, + blank=True, + help_text="Course where the moderation action was performed", + db_index=True, + ) + scope = models.CharField( + max_length=10, + blank=True, + help_text="Scope of mute action (personal or course)", + ) + reason = 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 +907,192 @@ 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, + on_delete=models.CASCADE, + related_name='forum_muted_by_users', + help_text='User being muted', + db_index=True, + ) + muted_by = 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, + 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( + max_length=255, + db_index=True, + help_text='Course in which mute applies' + ) + scope = 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( + 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(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): + """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): + 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, + 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, + 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( + max_length=255, + help_text='Course where the exception applies', + db_index=True, + ) + created = models.DateTimeField(auto_now_add=True) + modified = 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): + """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/management/commands/forum_create_mute_mongodb_indexes.py b/forum/management/commands/forum_create_mute_mongodb_indexes.py new file mode 100644 index 00000000..f1675f2e --- /dev/null +++ b/forum/management/commands/forum_create_mute_mongodb_indexes.py @@ -0,0 +1,262 @@ +""" +Management command to create MongoDB indexes for discussion mute functionality. +""" + +from django.core.management.base import BaseCommand +import pymongo +from pymongo import MongoClient +from pymongo.errors import OperationFailure + +from forum.backends.mongodb.mutes import ( + DiscussionMutes, + DiscussionMuteExceptions, + DiscussionModerationLogs +) + + +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): + 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, **options): + """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(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, drop_existing): + """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, drop_existing): + """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, drop_existing): + """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 \ No newline at end of file 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..8239bd0e --- /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", + ), + ), + ] \ No newline at end of file diff --git a/forum/serializers/mute.py b/forum/serializers/mute.py new file mode 100644 index 00000000..b8155ffc --- /dev/null +++ b/forum/serializers/mute.py @@ -0,0 +1,182 @@ +""" +Forum Mute/Unmute Serializers. +""" + +from rest_framework import serializers +from django.contrib.auth.models import User + +from forum.models import DiscussionMute, DiscussionMuteException, ModerationAuditLog + + +class MuteInputSerializer(serializers.Serializer): + """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" + ) + + +class UnmuteInputSerializer(serializers.Serializer): + """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)" + ) + + +class MuteAndReportInputSerializer(serializers.Serializer): + """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)" + ) + + +class UserMuteStatusSerializer(serializers.Serializer): + """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" + ) + + +class MutedUserSerializer(serializers.Serializer): + """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") + + +class CourseMutedUsersSerializer(serializers.Serializer): + """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" + ) + + +class DiscussionMuteSerializer(serializers.ModelSerializer): + """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): + """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): + """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'] \ No newline at end of file diff --git a/forum/views/mutes.py b/forum/views/mutes.py new file mode 100644 index 00000000..138c10dc --- /dev/null +++ b/forum/views/mutes.py @@ -0,0 +1,303 @@ +""" +Forum Mute / Unmute API Views. +""" + +import logging +from typing import Any + +from django.utils import timezone +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.models import ( + DiscussionMute, + DiscussionMuteException, + ModerationAuditLog, +) +from forum.serializers.mute import ( + MuteInputSerializer, + UnmuteInputSerializer, + UserMuteStatusSerializer, CourseMutedUsersSerializer, +) +from forum.utils import ForumV2RequestError +from forum.api.mutes import ( + mute_user, + unmute_user, + mute_and_report_user, + get_user_mute_status, + get_all_muted_users_for_course, +) + +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: + 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=moderator_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: + 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=moderator_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: + 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: + 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: + 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 + )