diff --git a/forum/__init__.py b/forum/__init__.py index 3a5afaa3..acbbc407 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.3.8" +__version__ = "0.5.3" diff --git a/forum/admin.py b/forum/admin.py index 3d624a8e..a6029925 100644 --- a/forum/admin.py +++ b/forum/admin.py @@ -14,6 +14,8 @@ UserVote, Subscription, MongoContent, + ModerationAuditLog, + DiscussionMuteRecord, ) @@ -55,11 +57,12 @@ class CommentThreadAdmin(admin.ModelAdmin): # type: ignore "context", "closed", "pinned", + "is_spam", "created_at", "updated_at", ) search_fields = ("title", "body", "author__username", "course_id") - list_filter = ("thread_type", "context", "closed", "pinned") + list_filter = ("thread_type", "context", "closed", "pinned", "is_spam") @admin.register(Comment) @@ -74,9 +77,10 @@ class CommentAdmin(admin.ModelAdmin): # type: ignore "updated_at", "endorsed", "anonymous", + "is_spam", ) search_fields = ("body", "author__username", "comment_thread__title") - list_filter = ("endorsed", "anonymous") + list_filter = ("endorsed", "anonymous", "is_spam") @admin.register(EditHistory) @@ -146,9 +150,129 @@ class SubscriptionAdmin(admin.ModelAdmin): # type: ignore list_filter = ("source_content_type",) +@admin.register(DiscussionMuteRecord) +class DiscussionMuteAdmin(admin.ModelAdmin): # type: ignore + """Admin interface for DiscussionMuteRecord 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(MongoContent) class MongoContentAdmin(admin.ModelAdmin): # type: ignore """Admin interface for MongoContent model.""" list_display = ("mongo_id", "content_object_id", "content_type") search_fields = ("mongo_id",) + + +@admin.register(ModerationAuditLog) +class ModerationAuditLogAdmin(admin.ModelAdmin): # type: ignore + """Admin interface for ModerationAuditLog model.""" + + list_display = ( + "timestamp", + "classification", + "actions_taken", + "body_preview", + "original_author", + "moderator_override", + "confidence_score", + ) + list_filter = ( + "classification", + "moderator_override", + "timestamp", + ) + search_fields = ( + "original_author__username", + "moderator__username", + "reasoning", + "override_reason", + "body", + ) + readonly_fields = ( + "timestamp", + "body", + "classifier_output", + "reasoning", + "classification", + "actions_taken", + "confidence_score", + "original_author", + ) + fieldsets = ( + ( + "Moderation Decision", + { + "fields": ( + "timestamp", + "classification", + "actions_taken", + "confidence_score", + "reasoning", + ) + }, + ), + ("Content Information", {"fields": ("body",)}), + ("Author Information", {"fields": ("original_author",)}), + ( + "Human Override", + { + "fields": ( + "moderator_override", + "moderator", + "override_reason", + ) + }, + ), + ( + "Technical Details", + { + "fields": ("classifier_output",), + "classes": ("collapse",), + }, + ), + ) + + def body_preview(self, obj): # type: ignore + """Return a truncated preview of the body for list display.""" + if obj.body: + return obj.body[:100] + "..." if len(obj.body) > 100 else obj.body + return "-" + + body_preview.short_description = "Body Preview" # type: ignore + + # pylint: disable=unused-argument + def has_add_permission(self, request): # type: ignore[no-untyped-def] + """Disable adding audit logs manually.""" + return False + + # pylint: disable=unused-argument + def has_delete_permission(self, request, obj=None): # type: ignore[no-untyped-def] + """Disable deleting audit logs to maintain integrity.""" + return False + + def get_queryset(self, request): # type: ignore + """Optimize queryset with related objects.""" + return ( + super() + .get_queryset(request) + .select_related("original_author", "moderator") + .order_by("-timestamp") + ) diff --git a/forum/ai_moderation.py b/forum/ai_moderation.py new file mode 100644 index 00000000..0f7a1816 --- /dev/null +++ b/forum/ai_moderation.py @@ -0,0 +1,361 @@ +""" +AI Moderation utilities for forum content. +""" + +import json +import logging +from typing import Dict, Optional, Any + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey +from rest_framework.serializers import ValidationError + +from forum.backends.mysql.models import ModerationAuditLog +from forum.utils import ForumV2RequestError + +User = get_user_model() +log = logging.getLogger(__name__) + + +def _get_author_from_content(content_instance: Any) -> Any: + """ + Get author from content instance. + + Args: + content_instance: Dict containing all content related data + Returns: + Author object or user ID + """ + author_id = content_instance.get("author_id") + if author_id: + try: + return User.objects.get(pk=author_id) + except (User.DoesNotExist, ValueError, TypeError): + # If we can't get the User object, return the ID as fallback + return author_id + return None + + +def create_moderation_audit_log( + content_instance: Any, + moderation_result: Dict[str, Any], + actions_taken: list[str], + original_author: Any, +) -> None: + """ + Create an audit log entry for AI moderation decisions. + + Only creates audit logs for spam content to reduce database load. + + Args: + content_instance: The content object (Thread or Comment, dict or model) + moderation_result: Full result from AI moderation + actions_taken: List of actions taken (e.g., ['flagged'], ['flagged', 'soft_deleted']) + original_author: User who created the content + """ + if original_author is None: + original_author = _get_author_from_content(content_instance) + + content_id = str(content_instance.get("_id")) + content_body = content_instance.get("body", "") + + enhanced_moderation_result = moderation_result.copy() + enhanced_moderation_result.update( + { + "content_id": content_id, + "metadata": { + "_id": content_id, + "title": content_instance.get("title", ""), + "body": ( + content_instance.get("body", "")[:200] + "..." + if len(content_instance.get("body", "")) > 200 + else content_instance.get("body", "") + ), + "course_id": content_instance.get("course_id", ""), + "created_at": str(content_instance.get("created_at", "")), + }, + } + ) + + try: + audit_log = ModerationAuditLog( + timestamp=timezone.now(), + body=content_body, # Store full body content + classifier_output=enhanced_moderation_result, + reasoning=moderation_result.get("reasoning", "No reasoning provided"), + classification=moderation_result.get("classification", "spam"), + actions_taken=actions_taken, + confidence_score=moderation_result.get("confidence_score"), + original_author=original_author, + ) + audit_log.save() + except (ValueError, TypeError, AttributeError) as db_error: + log.error(f"Failed to create database audit log: {db_error}") + + +class AIModerationService: + """ + Service for AI-based content moderation. + + Waffle Flag "discussion.enable_ai_moderation" controls whether AI moderation is active. + + XPERT AI Moderation API is used to classify content as spam or not spam. + """ + + def __init__(self): # type: ignore[no-untyped-def] + """Initialize the AI moderation service.""" + self.api_url = getattr(settings, "AI_MODERATION_API_URL", None) + self.client_id = getattr(settings, "AI_MODERATION_CLIENT_ID", None) + self.system_message = getattr(settings, "AI_MODERATION_SYSTEM_MESSAGE", None) + self.connection_timeout = getattr( + settings, "AI_MODERATION_CONNECTION_TIMEOUT", 30 + ) # seconds + self.read_timeout = getattr( + settings, "AI_MODERATION_READ_TIMEOUT", 30 + ) # seconds + self.ai_moderation_user_id = getattr(settings, "AI_MODERATION_USER_ID", None) + + def _make_api_request(self, content: str) -> Optional[Dict[str, Any]]: + """ + Make API request to XPert Service. + + Args: + content: The text content to moderate + + Returns: + Dictionary with 'reasoning' and 'classification' keys, or None if failed + """ + if not self.api_url: + log.error("AI_MODERATION_API_URL setting is not configured") + return None + + headers = { + "accept": "*/*", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "user-agent": "Mozilla/5.0 (compatible; edX-Forum-AI-Moderation/1.0)", + } + + payload = { + "messages": [{"role": "user", "content": content}], + "client_id": self.client_id, + "system_message": self.system_message, + } + + try: + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=(self.connection_timeout, self.read_timeout), + ) + response.raise_for_status() + + response_data = response.json() + # Validate response data structure + if not isinstance(response_data, list): + log.error( + f"Expected list response from XPert API, got {type(response_data)}" + ) + return None + + if len(response_data) == 0: + log.error("Empty response list from XPert API") + return None + + if not isinstance(response_data[0], dict): + log.error( + f"Expected dict in response list, got {type(response_data[0])}" + ) + return None + + assistant_content = response_data[0].get("content", "") + # Parse the JSON content from the assistant response + try: + moderation_result = json.loads(assistant_content) + # full API response for audit purposes + moderation_result["full_api_response"] = response_data + return moderation_result + except json.JSONDecodeError as e: + log.error(f"Failed to parse AI moderation response JSON: {e}") + return None + except ( + requests.RequestException, + requests.Timeout, + requests.ConnectionError, + ) as e: + log.error(f"AI moderation API request failed: {e}") + return None + + def moderate_and_flag_content( + self, + content: str, + content_instance: Any, + course_id: Optional[str] = None, + backend: Optional[Any] = None, + ) -> Dict[str, Any]: + """ + Moderate content and flag as spam and flag abuse if detected. + + Args: + content: The text content to check + content_instance: The content model instance (Thread or Comment) + course_id: Optional course ID for waffle flag checking + backend: Backend instance for database operations + + Returns: + Dictionary with moderation results and actions taken + """ + result = { + "is_spam": False, + "reasoning": "AI moderation disabled or unavailable", + "classification": "not_spam", + "actions_taken": ["no_action"], + "flagged": False, + } + # Check if AI moderation is enabled + # pylint: disable=import-outside-toplevel + from forum.toggles import ( + is_ai_moderation_enabled, + is_ai_auto_delete_spam_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] + return result + + # Make API request + moderation_result = self._make_api_request(content) + + if moderation_result is None: + result["reasoning"] = "AI moderation API failed" + log.warning("AI moderation API failed") + return result + + classification = moderation_result.get("classification", "not_spam") + reasoning = moderation_result.get("reasoning", "No reasoning provided") + is_spam = classification in ["spam", "spam_or_scam"] + + result.update( + { + "is_spam": is_spam, + "reasoning": reasoning, + "classification": classification, + "moderation_result": moderation_result, + } + ) + + if is_spam: + # Flag content as spam and abuse first + try: + content_instance["is_spam"] = True + + self._mark_as_spam_and_moderate(content_instance, backend) + result["actions_taken"] = ["flagged"] + result["flagged"] = True + except (AttributeError, ValueError, TypeError) as e: + log.error(f"Failed to flag content as spam: {e}") + result["actions_taken"] = ["no_action"] + + # Only attempt deletion if flagging succeeded + if is_ai_auto_delete_spam_enabled(course_key) and result["flagged"]: # type: ignore[no-untyped-call] + try: + self._delete_content(content_instance) + result["actions_taken"] = result["actions_taken"] + ["soft_deleted"] # type: ignore[operator] + except (ForumV2RequestError, ObjectDoesNotExist, ValidationError) as e: + log.error(f"Failed to delete content after flagging: {e}") + else: + result["actions_taken"] = ["no_action"] + + # Only create audit log for spam content (or API failures, handled above) + if is_spam: + create_moderation_audit_log( + content_instance, + moderation_result, + result["actions_taken"], # type: ignore[arg-type] + _get_author_from_content(content_instance), + ) + return result + + def _mark_as_spam_and_moderate(self, content_instance: Any, backend: Any) -> None: + """Flag content as abuse using backend methods.""" + content_id = str(content_instance.get("_id")) + content_type = str(content_instance.get("_type")) + extra_data = { + "entity_type": ( + "CommentThread" if content_type == "CommentThread" else "Comment" + ) + } + if not self.ai_moderation_user_id: + raise ValueError("AI_MODERATION_USER_ID setting is not configured.") + backend.flag_content_as_spam(content_type, content_id) + backend.flag_as_abuse(str(self.ai_moderation_user_id), content_id, **extra_data) + + def _delete_content(self, content_instance: Any) -> None: + """ + Soft delete content using API layer delete functions. + + Uses the API layer which handles all business logic including: + - Content validation + - Soft deletion + - Stats updates + - Subscription cleanup (for threads) + - Anonymous content handling + + Args: + content_instance: Dict containing content data including _id, _type, and course_id + """ + # Import here to avoid circular dependency (api modules import from ai_moderation) + # pylint: disable=import-outside-toplevel,cyclic-import + from forum.api.comments import delete_comment + from forum.api.threads import delete_thread + + content_id = str(content_instance.get("_id")) + content_type = str(content_instance.get("_type")) + course_id = content_instance.get("course_id") + deleted_by = ( + str(self.ai_moderation_user_id) if self.ai_moderation_user_id else None + ) + + # Use API layer functions which handle all business logic + # Exceptions propagate to caller for proper error handling + if content_type == "CommentThread": + delete_thread(content_id, course_id=course_id, deleted_by=deleted_by) + log.info(f"AI Moderation Deleted CommentThread: {content_id}") + elif content_type == "Comment": + delete_comment(content_id, course_id=course_id, deleted_by=deleted_by) + log.info(f"AI Moderation Deleted Comment: {content_id}") + + +# Global instance +ai_moderation_service = AIModerationService() # type: ignore[no-untyped-call] + + +def moderate_and_flag_spam( + content: str, + content_instance: Any, + course_id: Optional[str] = None, + backend: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Moderate content and flag as spam if detected. + + Args: + content: The text content to moderate + content_instance: The content model instance + course_id: Optional course ID for waffle flag checking + backend: Backend instance for database operations + + Returns: + Dictionary with moderation results and actions taken + + TODO:- + - Add content check for images + """ + return ai_moderation_service.moderate_and_flag_content( + content, content_instance, course_id, backend + ) diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..ef6ed3b9 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -2,20 +2,32 @@ Native Python APIs. """ +from .bans import ( + ban_user, + create_audit_log, + get_ban, + get_banned_users, + get_banned_usernames, + get_user_ban_scope, + is_user_banned, + unban_user, +) from .commentables import get_commentables_stats from .comments import ( create_child_comment, create_parent_comment, delete_comment, + delete_user_comments, get_course_id_by_comment, + get_deleted_comments_for_course, get_parent_comment, + get_user_comment_count, get_user_comments, + restore_comment, + restore_user_deleted_comments, update_comment, ) -from .flags import ( - update_comment_flag, - update_thread_flag, -) +from .flags import update_comment_flag, update_thread_flag from .pins import pin_thread, unpin_thread from .search import search_threads from .subscriptions import ( @@ -27,18 +39,29 @@ from .threads import ( create_thread, delete_thread, + delete_user_threads, get_course_id_by_thread, + get_deleted_threads_for_course, get_thread, get_user_threads, + get_user_threads_count, + restore_thread, + restore_user_deleted_threads, update_thread, ) from .users import ( create_user, + get_all_muted_users_for_course, + get_muted_users, get_user, get_user_active_threads, get_user_course_stats, + get_user_mute_status, mark_thread_as_read, + mute_and_report_user, + mute_user, retire_user, + unmute_user, update_user, update_username, update_users_in_course, @@ -51,6 +74,8 @@ ) __all__ = [ + "ban_user", + "create_audit_log", "create_child_comment", "create_parent_comment", "create_subscription", @@ -61,22 +86,44 @@ "delete_subscription", "delete_thread", "delete_thread_vote", + "delete_user_comments", + "delete_user_threads", + "get_all_muted_users_for_course", + "get_ban", + "get_banned_users", + "get_banned_usernames", "get_commentables_stats", "get_course_id_by_comment", "get_course_id_by_thread", + "get_deleted_comments_for_course", + "get_deleted_threads_for_course", + "get_muted_users", "get_parent_comment", "get_thread", "get_thread_subscriptions", "get_user", "get_user_active_threads", + "get_user_ban_scope", + "get_user_comment_count", "get_user_comments", "get_user_course_stats", + "get_user_mute_status", "get_user_subscriptions", "get_user_threads", + "get_user_threads_count", + "is_user_banned", "mark_thread_as_read", + "mute_and_report_user", + "mute_user", "pin_thread", + "restore_comment", + "restore_thread", + "restore_user_deleted_comments", + "restore_user_deleted_threads", "retire_user", "search_threads", + "unban_user", + "unmute_user", "unpin_thread", "update_comment", "update_comment_flag", diff --git a/forum/api/bans.py b/forum/api/bans.py new file mode 100644 index 00000000..a75e36e2 --- /dev/null +++ b/forum/api/bans.py @@ -0,0 +1,692 @@ +""" +API functions for managing discussion bans. +""" + +import logging +from typing import Any, Optional, Union + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser +from django.db import models, transaction +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey + +from forum.backends.mysql.models import ( + DiscussionBan, + DiscussionBanException, + ModerationAuditLog, +) + +User = get_user_model() +log = logging.getLogger(__name__) + + +def ban_user( + user: AbstractBaseUser, + banned_by: AbstractBaseUser, + course_id: Optional[Union[str, CourseKey]] = None, + org_key: Optional[str] = None, + scope: str = "course", + reason: str = "", +) -> dict[str, Any]: + """ + Ban a user from discussions. + + Args: + user: User object to ban + banned_by: User object performing the ban + course_id: Course ID for course-level bans + org_key: Organization key for org-level bans + scope: 'course' or 'organization' + reason: Reason for the ban + + Returns: + dict: Ban record data including id, user info, scope, and timestamps + + Raises: + ValueError: If invalid parameters provided + """ + if scope not in ["course", "organization"]: + raise ValueError(f"Invalid scope: {scope}. Must be 'course' or 'organization'") + + if scope == "course" and not course_id: + raise ValueError("course_id is required for course-level bans") + + if scope == "organization" and not (org_key or course_id): + raise ValueError("org_key or course_id is required for organization-level bans") + + # Use provided User objects + banned_user = user + moderator = banned_by + + with transaction.atomic(): + # Determine lookup kwargs based on scope + course_key = None # Initialize for audit log + if scope == "organization": + # Extract org_key from course_id if not provided + if not org_key and course_id: + if isinstance(course_id, str): + course_key = CourseKey.from_string(course_id) + else: + course_key = course_id + org_key = str(course_key.org) if hasattr(course_key, "org") else None + + if not org_key: + raise ValueError( + "org_key could not be determined for organization-level ban" + ) + + lookup_kwargs = { + "user": banned_user, + "org_key": org_key, + "scope": "organization", + } + ban_kwargs = { + **lookup_kwargs, + } + else: + # Normalize course_id + if isinstance(course_id, str): + course_key = CourseKey.from_string(course_id) + else: + course_key = course_id + # Extract org from course_id for denormalization + course_org = str(course_key.org) if hasattr(course_key, "org") else org_key # type: ignore[union-attr] + lookup_kwargs = { + "user": banned_user, + "course_id": course_key, + "scope": "course", + } + ban_kwargs = { + **lookup_kwargs, + "org_key": course_org, # Denormalized field for easier querying + } + + # Create or update ban + ban, created = DiscussionBan.objects.get_or_create( + **lookup_kwargs, + defaults={ + **ban_kwargs, + "banned_by": moderator, + "reason": reason or "No reason provided", + "is_active": True, + "banned_at": timezone.now(), + }, + ) + + reactivated = False + if not created and not ban.is_active: + # Reactivate previously deactivated ban + ban.is_active = True + ban.banned_by = moderator + ban.reason = reason or ban.reason + ban.banned_at = timezone.now() + ban.unbanned_at = None + ban.unbanned_by = None + ban.save() + reactivated = True + + # Clean up orphaned exceptions when creating/reactivating org-level bans + # This prevents situations where a user is re-banned at org level but + # still has exceptions from previous bans that should no longer apply + if (created or reactivated) and scope == "organization": + deleted_count = DiscussionBanException.objects.filter(ban=ban).delete()[0] + if deleted_count > 0: + log.info( + "Cleaned up %d orphaned exception(s) for org ban: ban_id=%s, user_id=%s", + deleted_count, + ban.id, + banned_user.id, # type: ignore[attr-defined] + ) + + # Create audit log + ModerationAuditLog.objects.create( + action_type=ModerationAuditLog.ACTION_BAN, + source=ModerationAuditLog.SOURCE_HUMAN, + target_user=banned_user, + moderator=moderator, + course_id=str(course_key) if course_key else None, + scope=scope, + reason=reason, + metadata={ + "ban_id": ban.id, + "created": created, + }, + # AI moderation fields (required by schema, not applicable for ban actions) + body="", + original_author=banned_user, + classification="", + classifier_output={}, + actions_taken=[], + confidence_score=None, + reasoning="", + moderator_override=False, + ) + + log.info( + "User banned: user_id=%s, scope=%s, course_id=%s, org_key=%s, banned_by=%s", + banned_user.id, # type: ignore[attr-defined] + scope, + course_id, + org_key, + moderator.id, # type: ignore[attr-defined] + ) + + result = _serialize_ban(ban) + if reactivated: + result["reactivated"] = True + return result + + +def unban_user( + ban_id: Optional[int] = None, + user: Optional[AbstractBaseUser] = None, + unbanned_by: Optional[AbstractBaseUser] = None, + course_id: Optional[Union[str, CourseKey]] = None, + scope: Optional[str] = None, + reason: str = "", +) -> dict[str, Any]: + """ + Unban a user from discussions. + + For course-level bans: Deactivates the ban completely. + For org-level bans with course_id: Creates an exception for that course. + For org-level bans without course_id: Deactivates the entire org ban. + + Args: + ban_id: ID of the ban to unban (optional if user provided) + user: User object to unban (optional if ban_id provided) + unbanned_by: User object performing the unban + course_id: Optional course ID for org-level ban exceptions + scope: Ban scope (course/organization) - used to find ban when user provided + reason: Reason for unbanning + + Returns: + dict: Response with status, message, and ban/exception data + + Raises: + DiscussionBan.DoesNotExist: If ban not found + ValueError: If neither ban_id nor user provided + """ + # Find the ban either by ID or by user + if ban_id: + try: + ban = DiscussionBan.objects.get(id=ban_id, is_active=True) + except DiscussionBan.DoesNotExist as exc: + raise ValueError(f"Active ban with id {ban_id} not found") from exc + elif user: + # Find active ban for this user based on scope + query = {"user": user, "is_active": True} + if scope: + query["scope"] = scope + # For course-level bans, include course_id + # For org-level bans, course_id is NULL in DB + if scope == "course" and course_id: + course_key = ( + CourseKey.from_string(course_id) + if isinstance(course_id, str) + else course_id + ) + query["course_id"] = course_key + try: + ban = DiscussionBan.objects.get(**query) + except DiscussionBan.DoesNotExist as exc: + raise ValueError( + f"No active ban found for user {user.username} with scope {scope}" # type: ignore[attr-defined] + ) from exc + else: + raise ValueError("Either ban_id or user must be provided") + + moderator = unbanned_by + exception_created = False + exception_data = None + + with transaction.atomic(): + # For org-level bans with course_id: create exception instead of full unban + if ban.scope == "organization" and course_id: + course_key = ( + CourseKey.from_string(course_id) + if isinstance(course_id, str) + else course_id + ) + + # Create exception for this specific course + exception, created = DiscussionBanException.objects.get_or_create( + ban=ban, + course_id=course_key, + defaults={ + "unbanned_by": moderator, + "reason": reason or "Course-level exception to organization ban", + }, + ) + + exception_created = True + exception_data = { + "id": exception.id, + "ban_id": ban.id, + "course_id": str(course_id), + "unbanned_by": moderator.get_username() if moderator else None, + "reason": exception.reason, + "created_at": ( + exception.created.isoformat() + if hasattr(exception, "created") + else None + ), + } + + message = ( + f"User {ban.user.username} unbanned from {course_id} " + f"(org-level ban still active for other courses)" + ) + + # Audit log for exception + ModerationAuditLog.objects.create( + action_type=ModerationAuditLog.ACTION_BAN_EXCEPTION, + source=ModerationAuditLog.SOURCE_HUMAN, + target_user=ban.user, + moderator=moderator, + course_id=str(course_key), + scope="organization", + reason=f"Exception to org ban: {reason}", + metadata={ + "ban_id": ban.id, + "exception_id": exception.id, + "exception_created": created, + "org_key": ban.org_key, + }, + # AI moderation fields (required by schema, not applicable for ban actions) + body="", + original_author=ban.user, + classification="", + classifier_output={}, + actions_taken=[], + confidence_score=None, + reasoning="", + moderator_override=False, + ) + else: + # Full unban (course-level or complete org-level unban) + ban.is_active = False + ban.unbanned_at = timezone.now() + ban.unbanned_by = moderator + ban.save() + + message = f"User {ban.user.username} unbanned successfully" + + # Audit log + ModerationAuditLog.objects.create( + action_type=ModerationAuditLog.ACTION_UNBAN, + source=ModerationAuditLog.SOURCE_HUMAN, + target_user=ban.user, + moderator=moderator, + course_id=str(ban.course_id) if ban.course_id else None, + scope=ban.scope, + reason=f"Unban: {reason}", + metadata={ + "ban_id": ban.id, + }, + # AI moderation fields (required by schema, not applicable for ban actions) + body="", + original_author=ban.user, + classification="", + classifier_output={}, + actions_taken=[], + confidence_score=None, + reasoning="", + moderator_override=False, + ) + + log.info( + "User unbanned: ban_id=%s, user_id=%s, exception_created=%s, unbanned_by=%s", + ban_id, + ban.user.id, + exception_created, + getattr(moderator, "id", None) if moderator else None, + ) + + return { + "status": "success", + "message": message, + "exception_created": exception_created, + "ban": _serialize_ban(ban), + "exception": exception_data, + } + + +def get_banned_users( + course_id: Optional[Union[str, CourseKey]] = None, + org_key: Optional[str] = None, + include_inactive: bool = False, + scope: Optional[str] = None, +) -> list[dict[str, Any]]: + """ + Get list of banned users. + + Args: + course_id: Filter by course ID (includes org-level bans for that course's org) + org_key: Filter by organization key + include_inactive: Include inactive (unbanned) users + scope: Filter by scope ('course' or 'organization') + + Returns: + list: List of ban records (excludes org-level bans with exceptions for the course) + """ + queryset = DiscussionBan.objects.select_related("user", "banned_by", "unbanned_by") + + if not include_inactive: + queryset = queryset.filter(is_active=True) + + if scope: + queryset = queryset.filter(scope=scope) + + if course_id: + course_key = ( + CourseKey.from_string(course_id) + if isinstance(course_id, str) + else course_id + ) + # Include both course-level bans and org-level bans for this course's org unless scope is specified + if not scope: + org = str(course_key.org) if hasattr(course_key, "org") else None + if org: + queryset = queryset.filter( + models.Q(course_id=course_key) + | models.Q(org_key=org, scope="organization") + ) + else: + # Fallback to just course-level bans if can't extract org + queryset = queryset.filter(course_id=course_key) + else: + # If scope is specified, just filter by course_id for course scope + if scope == "course": + queryset = queryset.filter(course_id=course_key) + # For org scope, we already filtered by scope above + elif org_key: + queryset = queryset.filter(org_key=org_key) + + queryset = queryset.order_by("-banned_at") + + # Filter out org-level bans that have exceptions for the requested course + # When a user with an org-level ban is "unbanned" at the course level, an exception + # is created that allows them in that specific course while keeping the org ban active. + # For course-specific banned user lists, we exclude org bans with exceptions since + # those users are effectively not banned in that particular course. + if course_id: + # course_key is already defined from the earlier if course_id block + # Use database-level filtering to avoid N+1 queries + exception_subquery = DiscussionBanException.objects.filter( + ban=OuterRef("pk"), course_id=course_key + ) + + queryset = queryset.annotate(has_exception=Exists(exception_subquery)).exclude( + scope="organization", has_exception=True + ) + + bans = list(queryset) + return [_serialize_ban(ban) for ban in bans] + + +def get_ban( + ban_id: Optional[int] = None, + user: Optional[AbstractBaseUser] = None, + course_id: Optional[Union[str, CourseKey]] = None, + scope: Optional[str] = None, +) -> Optional[dict[str, Any]]: + """ + Get a specific ban by ID or by user/course/scope. + + Args: + ban_id: ID of the ban (optional if user provided) + user: User object (optional if ban_id provided) + course_id: CourseKey or string (required with user) + scope: 'course' or 'organization' (optional with user) + + Returns: + dict: Ban record data, or None if not found + + Raises: + ValueError: If neither ban_id nor user provided + """ + try: + if ban_id: + ban = DiscussionBan.objects.select_related( + "user", "banned_by", "unbanned_by" + ).get(id=ban_id) + elif user: + query = {"user": user, "is_active": True} + if scope: + query["scope"] = scope + if course_id: + course_key = ( + CourseKey.from_string(course_id) + if isinstance(course_id, str) + else course_id + ) + query["course_id"] = course_key + ban = DiscussionBan.objects.select_related( + "user", "banned_by", "unbanned_by" + ).get(**query) + else: + raise ValueError("Either ban_id or user must be provided") + return _serialize_ban(ban) + except DiscussionBan.DoesNotExist: + return None + + +def _serialize_ban(ban: DiscussionBan) -> dict[str, Any]: + """ + Serialize a ban object to dictionary. + + Args: + ban: DiscussionBan instance + + Returns: + dict: Serialized ban data + """ + return { + "id": ban.id, + "user": { + "id": ban.user.id, + "username": ban.user.username, + "email": ban.user.email, + }, + "course_id": str(ban.course_id) if ban.course_id else None, + "org_key": ban.org_key, + "scope": ban.scope, + "reason": ban.reason, + "is_active": ban.is_active, + "banned_at": ban.banned_at.isoformat() if ban.banned_at else None, + "banned_by": ( + { + "id": ban.banned_by.id, + "username": ban.banned_by.username, + } + if ban.banned_by + else None + ), + "unbanned_at": ban.unbanned_at.isoformat() if ban.unbanned_at else None, + "unbanned_by": ( + { + "id": ban.unbanned_by.id, + "username": ban.unbanned_by.username, + } + if ban.unbanned_by + else None + ), + } + + +def is_user_banned( + user: AbstractBaseUser, + course_id: Optional[Union[str, CourseKey]], + check_org: bool = True, +) -> bool: + """ + Check if user is banned from discussions. + + Args: + user: User object or user ID + course_id: CourseKey or string + check_org: If True, also check organization-level bans + + Returns: + bool: True if user has active ban + """ + return DiscussionBan.is_user_banned(user, course_id, check_org) # type: ignore[no-untyped-call] + + +def get_user_ban_scope( + user: AbstractBaseUser, course_id: Optional[Union[str, CourseKey]] +) -> Optional[str]: + """ + Get the scope of a user's active ban ('course' or 'organization'). + + Args: + user: User object or user ID + course_id: CourseKey or string + + Returns: + str or None: 'course', 'organization', or None if not banned + """ + # Normalize course_id + if isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + + # Check organization-level ban first + try: + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.content.course_overviews.models import ( # type: ignore[import-not-found] + CourseOverview, + ) + + course = CourseOverview.objects.get(id=course_id) + org_name = course.org + except ImportError: + # CourseOverview not available (test environment or forum running standalone) + org_name = course_id.org # type: ignore[union-attr] + except Exception: # pylint: disable=broad-exception-caught + # Catch all other exceptions (DoesNotExist, AttributeError, cache errors, etc.) + # Similar to edx-platform's get_course_overview_or_none pattern + # See: openedx/core/djangoapps/content/course_overviews/api.py + log.debug( + "Could not fetch CourseOverview for %s, falling back to course_id.org", + course_id, + ) + org_name = course_id.org # type: ignore[union-attr] + + # Check org-level ban + org_ban = DiscussionBan.objects.filter( + user=user, + org_key=org_name, + scope=DiscussionBan.SCOPE_ORGANIZATION, + is_active=True, + ).first() + + if org_ban: + # Check if there's an exception for this course + if DiscussionBanException.objects.filter( + ban=org_ban, course_id=course_id + ).exists(): + # Exception exists - check for course-level ban + if DiscussionBan.objects.filter( + user=user, + course_id=course_id, + scope=DiscussionBan.SCOPE_COURSE, + is_active=True, + ).exists(): + return "course" + return None + return "organization" + + # Check course-level ban + if DiscussionBan.objects.filter( + user=user, + course_id=course_id, + scope=DiscussionBan.SCOPE_COURSE, + is_active=True, + ).exists(): + return "course" + + return None + + +def get_banned_usernames( + course_id: Optional[Union[str, CourseKey]] = None, org_key: Optional[str] = None +) -> set[str]: + """ + Get set of banned usernames for filtering from the learners list. + + This function is used to exclude banned users from the "All Other Learners" list. + ALL banned users (including staff if mistakenly banned) are returned so they + are properly excluded from the learners list and appear only in "Banned Users" section. + + Args: + course_id: CourseKey or string (optional) + org_key: Organization key string (optional) + + Returns: + set: Set of banned usernames (includes all banned users) + """ + queryset = DiscussionBan.objects.filter(is_active=True) + + if course_id: + if isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + + # Get org from course + organization = course_id.org if hasattr(course_id, "org") else org_key + + # Include both course-level and org-level bans + queryset = queryset.filter( + Q(course_id=course_id) | Q(org_key=organization, scope="organization") + ) + elif org_key: + queryset = queryset.filter(org_key=org_key) + + return set(queryset.values_list("user__username", flat=True)) + + +def create_audit_log( + action_type: str, + target_user: AbstractBaseUser, + moderator: AbstractBaseUser, + course_id: Optional[Union[str, CourseKey]] = None, + scope: Optional[str] = None, + reason: str = "", + metadata: Optional[dict[str, Any]] = None, +) -> ModerationAuditLog: + """ + Create a moderation audit log entry. + + Args: + action_type: Action type constant from ModerationAuditLog + target_user: User being moderated + moderator: User performing moderation + course_id: Course ID string (optional) + scope: Scope of action ('course' or 'organization') + reason: Reason for action + metadata: Additional metadata dict + + Returns: + ModerationAuditLog: Created audit log instance + """ + return ModerationAuditLog.objects.create( + action_type=action_type, + source=ModerationAuditLog.SOURCE_HUMAN, + target_user=target_user, + moderator=moderator, + course_id=course_id, + scope=scope, + reason=reason, + metadata=metadata or {}, + # AI moderation fields (required by schema, not applicable for ban actions) + body="", + original_author=target_user, + classification="", + classifier_output={}, + actions_taken=[], + confidence_score=None, + reasoning="", + moderator_override=False, + ) diff --git a/forum/api/comments.py b/forum/api/comments.py index edc14a1c..c6838461 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -9,6 +9,7 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework.serializers import ValidationError +from forum.ai_moderation import moderate_and_flag_spam from forum.backend import get_backend from forum.serializers.comment import CommentSerializer from forum.utils import ForumV2RequestError @@ -129,13 +130,21 @@ def create_child_comment( log.error("Forumv2RequestError for create child comment request.") raise ForumV2RequestError("comment is not created") + # AI Moderation: Check for spam after successful creation + try: + moderate_and_flag_spam(body, comment, course_id, backend) + # Get the updated comment after AI moderation + comment = backend.get_comment(comment_id) + except Exception as e: # pylint: disable=broad-except + log.error(f"AI moderation failed for child comment {comment_id}: {e}") + user = backend.get_user(user_id) thread = backend.get_thread(parent_comment["comment_thread_id"]) if user and thread and comment: backend.mark_as_read(user_id, parent_comment["comment_thread_id"]) try: comment_data = prepare_comment_api_response( - comment, + comment, # type: ignore[arg-type] backend, exclude_fields=["endorsement", "sk"], ) @@ -211,12 +220,16 @@ def update_comment( raise error -def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str, Any]: +def delete_comment( + comment_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None +) -> dict[str, Any]: """ Delete a comment. Parameters: comment_id: The ID of the comment to be deleted. + course_id: The ID of the course (optional). + deleted_by: The ID of the user performing the delete (optional). Body: Empty. Response: @@ -235,14 +248,33 @@ def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str backend, exclude_fields=["endorsement", "sk"], ) - backend.delete_comment(comment_id) author_id = comment["author_id"] comment_course_id = comment["course_id"] - parent_comment_id = data["parent_id"] - if parent_comment_id: - backend.update_stats_for_course(author_id, comment_course_id, replies=-1) + + # soft_delete_comment returns (responses_deleted, replies_deleted) + responses_deleted, replies_deleted = backend.soft_delete_comment( + comment_id, deleted_by + ) + + # Update stats based on what was actually deleted + if responses_deleted > 0: + # A response (parent comment) was deleted + backend.update_stats_for_course( + author_id, + comment_course_id, + responses=-responses_deleted, + deleted_responses=responses_deleted, + replies=-replies_deleted, + deleted_replies=replies_deleted, + ) else: - backend.update_stats_for_course(author_id, comment_course_id, responses=-1) + # Only a reply was deleted (no response) + backend.update_stats_for_course( + author_id, + comment_course_id, + replies=-replies_deleted, + deleted_replies=replies_deleted, + ) return data @@ -291,6 +323,13 @@ def create_parent_comment( log.error("Forumv2RequestError for create parent comment request.") raise ForumV2RequestError("comment is not created") comment = backend.get_comment(comment_id) or {} + try: + moderate_and_flag_spam(body, comment, course_id, backend) + # Get the updated comment after AI moderation + comment = backend.get_comment(comment_id) # type: ignore[assignment] + except Exception as e: # pylint: disable=broad-except + log.error(f"AI moderation failed for parent comment {comment_id}: {e}") + user = backend.get_user(user_id) if user and comment: backend.mark_as_read(user_id, thread_id) @@ -372,3 +411,99 @@ def get_user_comments( "num_pages": num_pages, "page": page, } + + +def get_deleted_comments_for_course( + course_id: str, page: int = 1, per_page: int = 20, author_id: Optional[str] = None +) -> dict[str, Any]: + """ + Get deleted comments for a specific course. + + Args: + course_id (str): The course identifier + page (int): Page number for pagination (default: 1) + per_page (int): Number of comments per page (default: 20) + author_id (str, optional): Filter by author ID + + Returns: + dict: Dictionary containing deleted comments and pagination info + """ + backend = get_backend(course_id)() + return backend.get_deleted_comments_for_course(course_id, page, per_page, author_id) + + +def restore_comment( + comment_id: str, course_id: Optional[str] = None, restored_by: Optional[str] = None +) -> bool: + """ + Restore a soft-deleted comment. + + Args: + comment_id (str): The ID of the comment to restore + course_id (str, optional): The course ID for backend selection + restored_by (str, optional): The ID of the user performing the restoration + + Returns: + bool: True if comment was restored, False if not found + """ + backend = get_backend(course_id)() + return backend.restore_comment(comment_id, restored_by=restored_by) + + +def restore_user_deleted_comments( + user_id: str, + course_ids: list[str], + course_id: Optional[str] = None, + restored_by: Optional[str] = None, +) -> int: + """ + Restore all deleted comments for a user across courses. + + Args: + user_id (str): The ID of the user whose comments to restore + course_ids (list): List of course IDs to restore comments in + course_id (str, optional): Course ID for backend selection (uses first from list if not provided) + restored_by (str, optional): The ID of the user performing the restoration + + Returns: + int: Number of comments restored + """ + backend = get_backend(course_id or course_ids[0])() + return backend.restore_user_deleted_comments( + user_id, course_ids, restored_by=restored_by + ) + + +def get_user_comment_count(user_id: str, course_ids: list[str]) -> int: + """ + Get the count of non-deleted comments for a user across courses. + + Args: + user_id (str): The ID of the user whose comments to count + course_ids (list): List of course IDs to search in + + Returns: + int: Number of non-deleted comments + """ + backend = get_backend(course_ids[0])() + return backend.get_user_comment_count(user_id, course_ids) + + +def delete_user_comments( + user_id: str, + course_ids: list[str], + deleted_by: Optional[str] = None, +) -> int: + """ + Delete all comments for a user across courses. + + Args: + user_id (str): The ID of the user whose comments to delete + course_ids (list): List of course IDs to delete comments from + deleted_by (str, optional): The ID of the user performing the deletion + + Returns: + int: Number of comments deleted + """ + backend = get_backend(course_ids[0])() + return backend.delete_user_comments(user_id, course_ids, deleted_by=deleted_by) diff --git a/forum/api/search.py b/forum/api/search.py index bec053d4..60c5ea00 100644 --- a/forum/api/search.py +++ b/forum/api/search.py @@ -75,6 +75,7 @@ def search_threads( page: int = FORUM_DEFAULT_PAGE, per_page: int = FORUM_DEFAULT_PER_PAGE, is_moderator: bool = False, + is_deleted: bool = False, ) -> dict[str, Any]: """ Search for threads based on the provided data. @@ -107,6 +108,7 @@ def search_threads( raw_query=False, commentable_ids=commentable_ids, is_moderator=is_moderator, + is_deleted=is_deleted, ) if collections := data.get("collection"): diff --git a/forum/api/threads.py b/forum/api/threads.py index b5b036c5..b26c71bb 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -8,6 +8,7 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework.serializers import ValidationError +from forum.ai_moderation import moderate_and_flag_spam from forum.api.users import mark_thread_as_read from forum.backend import get_backend from forum.serializers.thread import ThreadSerializer @@ -158,12 +159,16 @@ def get_thread( raise ForumV2RequestError("Failed to prepare thread API response") from error -def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str, Any]: +def delete_thread( + thread_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None +) -> dict[str, Any]: """ Delete the thread for the given thread_id. Parameters: thread_id: The ID of the thread to be deleted. + course_id: The ID of the course (optional). + deleted_by: The ID of the user performing the delete (optional). Response: The details of the thread that is deleted. """ @@ -176,7 +181,9 @@ def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str, f"Thread does not exist with Id: {thread_id}" ) from exc - backend.delete_comments_of_a_thread(thread_id) + count_of_response_deleted, count_of_replies_deleted = ( + backend.soft_delete_comments_of_a_thread(thread_id, deleted_by) + ) thread = backend.validate_object("CommentThread", thread_id) try: @@ -186,10 +193,17 @@ def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str, raise ForumV2RequestError("Failed to prepare thread API response") from error backend.delete_subscriptions_of_a_thread(thread_id) - result = backend.delete_thread(thread_id) + result = backend.soft_delete_thread(thread_id, deleted_by) if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): backend.update_stats_for_course( - thread["author_id"], thread["course_id"], threads=-1 + thread["author_id"], + thread["course_id"], + threads=-1, + responses=-count_of_response_deleted, + replies=-count_of_replies_deleted, + deleted_threads=1, + deleted_responses=count_of_response_deleted, + deleted_replies=count_of_replies_deleted, ) return serialized_data @@ -327,15 +341,22 @@ def create_thread( thread = backend.get_thread(thread_id) if not thread: raise ForumV2RequestError(f"Failed to create thread with data: {data}") + try: + combined_content = f"{title}\n\n{body}" + moderate_and_flag_spam(combined_content, thread, course_id, backend) + # Get the updated thread after AI moderation + thread = backend.get_thread(thread_id) + except Exception as e: # pylint: disable=broad-except + log.error(f"AI moderation failed for thread {thread_id}: {e}") if not (anonymous or anonymous_to_peers): backend.update_stats_for_course( - thread["author_id"], thread["course_id"], threads=1 + thread["author_id"], thread["course_id"], threads=1 # type: ignore[index] ) try: return prepare_thread_api_response( - thread, + thread, # type: ignore[arg-type] backend, True, data, @@ -385,6 +406,8 @@ def get_user_threads( "user_id": user_id, "group_id": group_id, "group_ids": group_ids, + "is_deleted": kwargs.get("is_deleted", False), + "context": kwargs.get("context"), } params = {k: v for k, v in params.items() if v is not None} backend.validate_params(params) @@ -411,3 +434,99 @@ def get_course_id_by_thread(thread_id: str) -> str | None: or MySQLBackend.get_course_id_by_thread_id(thread_id) or None ) + + +def get_deleted_threads_for_course( + course_id: str, page: int = 1, per_page: int = 20, author_id: Optional[str] = None +) -> dict[str, Any]: + """ + Get deleted threads for a specific course. + + Args: + course_id (str): The course identifier + page (int): Page number for pagination (default: 1) + per_page (int): Number of threads per page (default: 20) + author_id (str, optional): Filter by author ID + + Returns: + dict: Dictionary containing deleted threads and pagination info + """ + backend = get_backend(course_id)() + return backend.get_deleted_threads_for_course(course_id, page, per_page, author_id) + + +def restore_thread( + thread_id: str, course_id: Optional[str] = None, restored_by: Optional[str] = None +) -> bool: + """ + Restore a soft-deleted thread. + + Args: + thread_id (str): The ID of the thread to restore + course_id (str, optional): The course ID for backend selection + restored_by (str, optional): The ID of the user performing the restoration + + Returns: + bool: True if thread was restored, False if not found + """ + backend = get_backend(course_id)() + return backend.restore_thread(thread_id, restored_by=restored_by) + + +def restore_user_deleted_threads( + user_id: str, + course_ids: list[str], + course_id: Optional[str] = None, + restored_by: Optional[str] = None, +) -> int: + """ + Restore all deleted threads for a user across courses. + + Args: + user_id (str): The ID of the user whose threads to restore + course_ids (list): List of course IDs to restore threads in + course_id (str, optional): Course ID for backend selection (uses first from list if not provided) + restored_by (str, optional): The ID of the user performing the restoration + + Returns: + int: Number of threads restored + """ + backend = get_backend(course_id or course_ids[0])() + return backend.restore_user_deleted_threads( + user_id, course_ids, restored_by=restored_by + ) + + +def get_user_threads_count(user_id: str, course_ids: list[str]) -> int: + """ + Get the count of non-deleted threads for a user across courses. + + Args: + user_id (str): The ID of the user whose threads to count + course_ids (list): List of course IDs to search in + + Returns: + int: Number of non-deleted threads + """ + backend = get_backend(course_ids[0])() + return backend.get_user_threads_count(user_id, course_ids) + + +def delete_user_threads( + user_id: str, + course_ids: list[str], + deleted_by: Optional[str] = None, +) -> int: + """ + Delete all threads for a user across courses. + + Args: + user_id (str): The ID of the user whose threads to delete + course_ids (list): List of course IDs to delete threads from + deleted_by (str, optional): The ID of the user performing the deletion + + Returns: + int: Number of threads deleted + """ + backend = get_backend(course_ids[0])() + return backend.delete_user_threads(user_id, course_ids, deleted_by=deleted_by) diff --git a/forum/api/users.py b/forum/api/users.py index 71c3a36e..fbe5608b 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -4,9 +4,12 @@ import logging import math -from typing import Any, Optional +from typing import Any, Dict, Optional +from datetime import datetime +from django.http import HttpRequest from forum.backend import get_backend +from forum.backends.mysql.api import MySQLBackend from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE from forum.serializers.thread import ThreadSerializer from forum.serializers.users import UserSerializer @@ -135,14 +138,19 @@ def retire_user( user = backend.get_user(user_id) if not user: raise ForumV2RequestError(f"user not found with id: {user_id}") - backend.update_user( - user_id, - data={ - "email": "", - "username": retired_username, - "read_states": [], - }, - ) + + # Prepare update data + data = { + "username": retired_username, + "read_states": [], + } + + # MongoDB backend owns user data and should blank email during retirement + # MySQL backend shares auth_user with LMS - LMS manages email field + if not isinstance(backend, MySQLBackend): + data["email"] = "" + + backend.update_user(user_id, data=data) backend.unsubscribe_all(user_id) backend.retire_all_content(user_id, retired_username) @@ -198,6 +206,7 @@ def get_user_active_threads( per_page: Optional[int] = FORUM_DEFAULT_PER_PAGE, group_id: Optional[str] = None, is_moderator: Optional[bool] = False, + show_deleted: Optional[bool] = False, ) -> dict[str, Any]: """Get user active threads.""" backend = get_backend(course_id)() @@ -251,6 +260,7 @@ def get_user_active_threads( "context": "course", "raw_query": raw_query, "is_moderator": is_moderator, + "is_deleted": show_deleted, } data = backend.handle_threads_query(**params) @@ -320,7 +330,7 @@ def get_user_course_stats( """Get user course stats.""" backend = get_backend(course_id)() sort_criterion = backend.get_user_sort_criterion(sort_key) - exclude_from_stats = ["_id", "course_id"] + exclude_from_stats = ["_id", "course_id", "deleted_count"] if not with_timestamps: exclude_from_stats.append("last_activity_at") @@ -370,3 +380,314 @@ def update_users_in_course(course_id: str) -> dict[str, int]: backend = get_backend(course_id)() updated_users = backend.update_all_users_in_course(course_id) return {"user_count": len(updated_users)} + + +def mute_user( + muted_user_id: str, + muter_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + requester_is_privileged: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muter_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + requester_is_privileged: Whether requester has course-level privileges + + Returns: + Dictionary containing mute record data + """ + try: + backend = get_backend(course_id)() + return backend.mute_user( + muted_user_id=muted_user_id, + muter_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + requester_is_privileged=requester_is_privileged, + **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", + muter_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') + muter_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, + muter_id=muter_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( + muter_id: str, course_id: str, scope: str = "all", **kwargs: Any +) -> list[dict[str, Any]]: + """ + Get list of users muted by a specific user. + + Args: + muter_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=muter_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, + muter_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + thread_id: str = "", + comment_id: str = "", + request: Optional[HttpRequest] = None, + requester_is_privileged: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """ + Mute a user and flag their content as abusive in discussions. + + Args: + muted_user_id: ID of user to mute + muter_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + thread_id: Optional content ID to flag (tries as thread, then comment) + comment_id: Optional comment ID to flag as abusive + request: Django request object for content flagging + requester_is_privileged: Whether requester has course-level privileges + **kwargs: Additional parameters to pass to backend.mute_user + + Returns: + Dictionary containing mute and report operation result + """ + try: + backend = get_backend(course_id)() + + # Mute the user first + mute_result = backend.mute_user( + muted_user_id=muted_user_id, + muter_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + requester_is_privileged=requester_is_privileged, + **kwargs, + ) + + # Handle content flagging if request provided + flagged_items = [] + should_flag_content = (thread_id or comment_id) and request + + if should_flag_content and request: + user_id = str(getattr(request.user, "id", "")) + + # Flag thread_id (may be thread or comment) + if thread_id: + try: + backend.flag_as_abuse( + user_id=user_id, + entity_id=thread_id, + entity_type="CommentThread", + ) + result = { + "content_type": "thread", + "content_id": thread_id, + "flagged": True, + } + except Exception as e: # pylint: disable=broad-except + log.warning("Failed to flag thread %s: %s", thread_id, str(e)) + # Retry as Comment + try: + backend.flag_as_abuse( + user_id=user_id, entity_id=thread_id, entity_type="Comment" + ) + result = { + "content_type": "comment", + "content_id": thread_id, + "flagged": True, + } + except Exception as e2: # pylint: disable=broad-except + log.warning("Failed to flag comment %s: %s", thread_id, str(e2)) + result = { + "content_type": "comment", + "content_id": thread_id, + "flagged": False, + "error": str(e2), + } + flagged_items.append(result) + + # Flag comment_id separately + if comment_id: + try: + backend.flag_as_abuse( + user_id=user_id, entity_id=comment_id, entity_type="Comment" + ) + flagged_items.append( + { + "content_type": "comment", + "content_id": comment_id, + "flagged": True, + } + ) + except Exception as e: # pylint: disable=broad-except + log.warning("Failed to flag comment %s: %s", comment_id, str(e)) + flagged_items.append( + { + "content_type": "comment", + "content_id": comment_id, + "flagged": False, + "error": str(e), + } + ) + + # Build report result based on flagged content + if flagged_items: + all_flagged = all(item["flagged"] for item in flagged_items) + report_result = { + "status": "success" if all_flagged else "partial", + "flagged_items": flagged_items, + } + message = "User muted and content flagged" + else: + report_result = { + "status": "success", + "report_id": f"report_{muted_user_id}_{muter_id}_{course_id}", + "reported_user_id": muted_user_id, + "reported_by_id": muter_id, + "course_id": course_id, + "reason": reason, + "created": datetime.utcnow().isoformat(), + "message": "User reported (no specific content flagged)", + } + message = "User muted and reported" + + return { + "status": "success", + "message": message, + "mute_record": mute_result, + "report_record": report_result, + } + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: # pylint: disable=broad-except + 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", + requester_is_privileged: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """ + Get all muted users in a course with role-based access control. + + Args: + course_id: Course identifier + requester_id: ID of the user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + requester_is_privileged: Whether the requester has course-level privileges + + Returns: + Dictionary containing list of muted users based on requester role and scope + + Authorization: + - Learners: Can only see their own personal mutes + - Staff: Can see course-wide mutes and all personal mutes + """ + try: + backend = get_backend(course_id)() + return backend.get_all_muted_users_for_course( + course_id=course_id, + requester_id=requester_id, + scope=scope, + requester_is_privileged=requester_is_privileged, + **kwargs, + ) + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: # pylint: disable=broad-except + raise ForumV2RequestError(f"Failed to get course muted users: {str(e)}") from e 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..9f6503ae 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -223,7 +223,9 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: raise NotImplementedError @classmethod - def get_user_voted_ids(cls, user_id: str, vote: str) -> list[str]: + def get_user_voted_ids( + cls, user_id: str, vote: str, course_id: Optional[str] = None + ) -> list[str]: """Get user voted ids.""" raise NotImplementedError @@ -476,3 +478,176 @@ 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, + muter_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + requester_is_privileged: bool = False, + **kwargs: Any + ) -> dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muter_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + requester_is_privileged: Whether requester has course-level privileges + + 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", + muter_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') + muter_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 mute_and_report_user( + cls, + muted_user_id: str, + muter_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 + muter_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", + requester_is_privileged: bool = False, + **kwargs: Any + ) -> dict[str, Any]: + """ + Get all muted users in a course with role-based access control. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + requester_is_privileged: Whether the requester is a privileged user (staff/instructor/etc) + + Returns: + Dictionary containing list of muted users based on requester role and scope + + Authorization: + - Learners: Can only see their own personal mutes + - Staff: Can see course-wide mutes and all personal mutes + """ + raise NotImplementedError + + @staticmethod + def get_deleted_threads_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """ + Get deleted threads for a specific course. + """ + raise NotImplementedError + + @staticmethod + def get_deleted_comments_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """ + Get deleted comments for a specific course. + """ + raise NotImplementedError diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index b279ac8e..ef28675f 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1,20 +1,25 @@ +# pylint: disable=cyclic-import """Model util function for db operations.""" import math from datetime import datetime, timezone +from functools import wraps from typing import Any, Optional -from bson import ObjectId, errors as bson_errors -from django.core.exceptions import ObjectDoesNotExist +from bson import ObjectId +from bson import errors as bson_errors +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db.models import Q +from django.utils.timezone import now from forum.backends.backend import AbstractBackend -from forum.backends.mongodb import ( - Comment, - CommentThread, - Contents, - Subscriptions, - Users, -) +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.contents import Contents +from forum.backends.mongodb.subscriptions import Subscriptions +from forum.backends.mongodb.threads import CommentThread +from forum.backends.mongodb.users import Users +from forum.backends.mysql.models import DiscussionMuteRecord, ModerationAuditLog from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -24,10 +29,109 @@ str_to_bool, ) +User = get_user_model() + class MongoBackend(AbstractBackend): """Mongodb Backend API.""" + @staticmethod + def _handle_mute_errors(func: Any) -> Any: + """Simple decorator for mute operation error handling.""" + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except User.DoesNotExist as e: + raise ForumV2RequestError(f"User not found: {e}") from e + except ValidationError as ve: + raise ForumV2RequestError(f"Validation error: {ve}") from ve + except ValueError as e: + raise ForumV2RequestError(str(e)) from e + except Exception as e: + operation = func.__name__.replace("_", " ") + raise ForumV2RequestError(f"Failed to {operation}: {str(e)}") from e + + return wrapper + + @staticmethod + def _create_audit_log( + action_type: str, + user_id: str, + course_id: str, + muted_user: Any, + muter_user: Any, + reason: str = "", + **extras: Any, + ) -> None: + """Create audit log entry for mute operations.""" + try: + ModerationAuditLog( + timestamp=datetime.now(timezone.utc), + body=f"User {action_type}: {user_id}", + classifier_output={ + "action_type": action_type, + "course_id": course_id, + "muted_user_id": user_id, + "backend": "mongodb", + **extras, + }, + reasoning=reason or "No reason provided", + actions_taken=[f"user_{action_type}"], + original_author=muted_user, + moderator=muter_user, + ).save() + except Exception: # pylint: disable=broad-exception-caught + # Don't fail operations due to audit logging issues + pass + + @staticmethod + def user_has_privileges(user: object, course_id: Optional[str] = None) -> bool: + """Check if user has discussion moderation privileges. + + Returns True only for: + - Global staff (is_staff or is_superuser) + - Discussion moderators, administrators, and community TAs + + Course staff and instructors return False - they do NOT have moderation privileges. + + Args: + user: User object to check + course_id: Optional course ID to check privileges for specific course. + If None, checks if user has ANY privileges across all courses. + + Returns: + True if user has discussion moderation privileges (in specified course if course_id provided) + """ + # Global Django staff + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + # Check for discussion forum roles (Moderator, Administrator, Community TA, Group Moderator) + # These are stored in role_set with specific names + # Course staff roles are in courseaccessrole_set, which we intentionally exclude + if hasattr(user, "role_set"): + protected_roles = { + "Moderator", + "Administrator", + "Community TA", + "Group Moderator", + } + + if course_id: + # Check for discussion roles in specific course + role_names = user.role_set.filter(course_id=course_id).values_list( + "name", flat=True + ) + else: + # Check for discussion roles in any course + role_names = user.role_set.values_list("name", flat=True) + + return any(role in protected_roles for role in role_names) + + return False + @classmethod def update_stats_for_course( cls, user_id: str, course_id: str, **kwargs: Any @@ -39,13 +143,9 @@ def update_stats_for_course( course_stats = user.get("course_stats", []) for course_stat in course_stats: if course_stat["course_id"] == course_id: - course_stat.update( - { - k: course_stat[k] + v - for k, v in kwargs.items() - if k in course_stat - } - ) + # Update existing fields and add new fields if they don't exist + for k, v in kwargs.items(): + course_stat[k] = course_stat.get(k, 0) + v Users().update( user_id, course_stats=course_stats, @@ -415,6 +515,7 @@ def handle_pin_unpin_thread_request( def get_abuse_flagged_count(thread_ids: list[str]) -> dict[str, int]: """ Retrieves the count of abuse-flagged comments for each thread in the provided list of thread IDs. + Only counts non-deleted comments. Args: thread_ids (list[str]): List of thread IDs to check for abuse flags. @@ -427,6 +528,7 @@ def get_abuse_flagged_count(thread_ids: list[str]) -> dict[str, int]: "$match": { "comment_thread_id": {"$in": [ObjectId(tid) for tid in thread_ids]}, "abuse_flaggers": {"$ne": []}, + "is_deleted": {"$ne": True}, } }, {"$group": {"_id": "$comment_thread_id", "flagged_count": {"$sum": 1}}}, @@ -555,6 +657,7 @@ def handle_threads_query( raw_query: bool = False, commentable_ids: Optional[list[str]] = None, is_moderator: bool = False, + is_deleted: bool = False, ) -> dict[str, Any]: """ Handles complex thread queries based on various filters and returns paginated results. @@ -578,6 +681,7 @@ def handle_threads_query( raw_query (bool): Whether to return raw query results without further processing. commentable_ids (Optional[list[str]]): List of commentable IDs to filter threads by topic id. is_moderator (bool): Whether the user is a discussion moderator. + is_deleted (bool): If True, include deleted content; if False (default), exclude deleted content. Returns: dict[str, Any]: A dictionary containing the paginated thread results and associated metadata. @@ -598,6 +702,10 @@ def handle_threads_query( "context": context, } + # Include/exclude deleted content based on is_deleted parameter + if not is_deleted: + base_query["is_deleted"] = {"$ne": True} # Exclude soft deleted threads + # Group filtering if group_ids: base_query["$or"] = [ @@ -909,6 +1017,28 @@ def delete_comments_of_a_thread(thread_id: str) -> None: ): Comment().delete(comment["_id"]) + @staticmethod + def soft_delete_comments_of_a_thread( + thread_id: str, deleted_by: Optional[str] = None + ) -> tuple[int, int]: + """Soft delete all comments of a thread by marking them as deleted.""" + count_of_response_deleted = 0 + count_of_replies_deleted = 0 + query_params = { + "comment_thread_id": ObjectId(thread_id), + "depth": 0, + "parent_id": None, + "is_deleted": {"$ne": True}, + } + for comment in Comment().get_list(**query_params): + responses, replies = Comment().delete( + comment["_id"], deleted_by=deleted_by, mode="soft" + ) + count_of_response_deleted += responses + count_of_replies_deleted += replies + + return count_of_response_deleted, count_of_replies_deleted + @staticmethod def delete_subscriptions_of_a_thread(thread_id: str) -> None: """Delete subscriptions of a thread.""" @@ -946,8 +1076,10 @@ def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> No "per_page", "request_id", "commentable_ids", + "context", "group_id", "group_ids", + "is_deleted", ] if not user_id: valid_params.append("user_id") @@ -990,6 +1122,8 @@ def get_threads( params.get("sort_key", ""), int(params.get("page", 1)), int(params.get("per_page", 100)), + params.get("context", "course"), + bool(params.get("raw_query", False)), commentable_ids=params.get("commentable_ids", []), is_moderator=params.get("is_moderator", False), ) @@ -1044,6 +1178,7 @@ def create_comment(cls, data: dict[str, Any]) -> str: depth=data.get("depth", 0), comment_thread_id=data["comment_thread_id"], parent_id=data.get("parent_id"), + is_spam=data.get("is_spam", False), ) if data.get("parent_id"): @@ -1128,18 +1263,23 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: return commentable_counts @classmethod - def get_user_voted_ids(cls, user_id: str, vote: str) -> list[str]: + def get_user_voted_ids( + cls, user_id: str, vote: str, course_id: Optional[str] = None + ) -> list[str]: """Get the IDs of the posts voted by a user.""" if vote not in ["up", "down"]: raise ValueError("Invalid vote type") content_model = Contents() - contents = content_model.get_list() + content_query: dict[str, Any] = {} + if course_id: + content_query["course_id"] = str(course_id) + content_query[f"votes.{vote}"] = user_id + + contents = content_model.get_list(**content_query) voted_ids = [] for content in contents: - votes = content["votes"][vote] - if user_id in votes: - voted_ids.append(content["_id"]) + voted_ids.append(content["_id"]) return voted_ids @@ -1176,8 +1316,12 @@ def user_to_hash( if params.get("complete"): subscribed_thread_ids = cls.find_subscribed_threads(user["external_id"]) - upvoted_ids = cls.get_user_voted_ids(user["external_id"], "up") - downvoted_ids = cls.get_user_voted_ids(user["external_id"], "down") + upvoted_ids = cls.get_user_voted_ids( + user["external_id"], "up", params.get("course_id") + ) + downvoted_ids = cls.get_user_voted_ids( + user["external_id"], "down", params.get("course_id") + ) hash_data.update( { "subscribed_thread_ids": subscribed_thread_ids, @@ -1362,6 +1506,9 @@ def find_or_create_user_stats(user_id: str, course_id: str) -> dict[str, Any]: "threads": 0, "responses": 0, "replies": 0, + "deleted_threads": 0, + "deleted_responses": 0, + "deleted_replies": 0, "course_id": course_id, "last_activity_at": "", } @@ -1455,10 +1602,51 @@ def build_course_stats(cls, author_id: str, course_id: str) -> None: active_flags += counts["active_flags"] inactive_flags += counts["inactive_flags"] + # Count deleted content + deleted_pipeline = [ + { + "$match": { + "course_id": course_id, + "author_id": user["external_id"], + "anonymous_to_peers": False, + "anonymous": False, + "is_deleted": True, + } + }, + { + "$addFields": { + "is_reply": {"$ne": [{"$ifNull": ["$parent_id", None]}, None]} + } + }, + { + "$group": { + "_id": {"type": "$_type", "is_reply": "$is_reply"}, + "count": {"$sum": 1}, + } + }, + ] + + deleted_data = list(Contents().aggregate(deleted_pipeline)) + deleted_threads = 0 + deleted_responses = 0 + deleted_replies = 0 + + for counts in deleted_data: + _type, is_reply = counts["_id"]["type"], counts["_id"]["is_reply"] + if _type == "Comment" and is_reply: + deleted_replies = counts["count"] + elif _type == "Comment" and not is_reply: + deleted_responses = counts["count"] + else: + deleted_threads = counts["count"] + stats = cls.find_or_create_user_stats(user["external_id"], course_id) stats["replies"] = replies stats["responses"] = responses stats["threads"] = threads + stats["deleted_threads"] = deleted_threads + stats["deleted_responses"] = deleted_responses + stats["deleted_replies"] = deleted_replies stats["active_flags"] = active_flags stats["inactive_flags"] = inactive_flags stats["last_activity_at"] = updated_at @@ -1500,8 +1688,6 @@ def get_comment(comment_id: str) -> dict[str, Any] | None: def get_thread(thread_id: str) -> dict[str, Any] | None: """Get thread from id.""" thread = CommentThread().get(thread_id) - if not thread: - return None return thread @staticmethod @@ -1534,6 +1720,17 @@ def delete_comment(comment_id: str) -> None: """Delete comment.""" Comment().delete(comment_id) + @staticmethod + def soft_delete_comment( + comment_id: str, deleted_by: Optional[str] = None + ) -> tuple[int, int]: + """Soft delete comment by marking it as deleted. + + Returns: + tuple: (responses_deleted, replies_deleted) + """ + return Comment().delete(comment_id, mode="soft", deleted_by=deleted_by) + @staticmethod def get_thread_id_from_comment(comment_id: str) -> dict[str, Any] | None: """Return thread_id from comment_id.""" @@ -1567,6 +1764,83 @@ def delete_thread(thread_id: str) -> int: """Delete thread.""" return CommentThread().delete(thread_id) + @staticmethod + def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: + """Soft delete thread by marking it as deleted.""" + return CommentThread().update( + thread_id, is_deleted=True, deleted_at=datetime.now(), deleted_by=deleted_by + ) + + @staticmethod + def restore_comment(comment_id: str, restored_by: Optional[str] = None) -> bool: + """Restore a soft-deleted comment.""" + return Comment().restore_comment(comment_id, restored_by=restored_by) + + @staticmethod + def restore_thread(thread_id: str, restored_by: Optional[str] = None) -> bool: + """Restore a soft-deleted thread.""" + return CommentThread().restore_thread(thread_id, restored_by=restored_by) + + @staticmethod + def restore_user_deleted_comments( + user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """Restore all deleted comments for a user in given courses.""" + return Comment().restore_user_deleted_comments( + user_id, course_ids, restored_by=restored_by + ) + + @staticmethod + def restore_user_deleted_threads( + user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """Restore all deleted threads for a user in given courses.""" + return CommentThread().restore_user_deleted_threads( + user_id, course_ids, restored_by=restored_by + ) + + @staticmethod + def get_user_threads_count(user_id: str, course_ids: list[str]) -> int: + """Get count of non-deleted threads for a user in given courses.""" + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": {"$ne": True}, + "_type": "CommentThread", + } + # pylint: disable=protected-access + return CommentThread()._collection.count_documents(query_params) + + @staticmethod + def get_user_comment_count(user_id: str, course_ids: list[str]) -> int: + """Get count of non-deleted comments for a user in given courses.""" + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": {"$ne": True}, + "_type": "Comment", + } + # pylint: disable=protected-access + return Comment()._collection.count_documents(query_params) + + @staticmethod + def delete_user_threads( + user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """Delete all threads for a user in given courses.""" + return CommentThread().delete_user_threads( + user_id, course_ids, deleted_by=deleted_by + ) + + @staticmethod + def delete_user_comments( + user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """Delete all comments for a user in given courses.""" + return Comment().delete_user_comments( + user_id, course_ids, deleted_by=deleted_by + ) + @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1586,6 +1860,7 @@ def create_thread(data: dict[str, Any]) -> str: abuse_flaggers=data.get("abuse_flaggers"), historical_abuse_flaggers=data.get("historical_abuse_flaggers"), group_id=data.get("group_id"), + is_spam=data.get("is_spam", False), ) return new_thread_id @@ -1678,6 +1953,11 @@ def get_user_sort_criterion(sort_by: str) -> dict[str, Any]: "course_stats.last_activity_at": -1, "username": -1, } + elif sort_by == "deleted": + return { + "course_stats.deleted_count": -1, + "username": -1, + } else: return { "course_stats.threads": -1, @@ -1696,6 +1976,26 @@ def create_user_pipeline( {"$project": {"username": 1, "course_stats": 1}}, {"$unwind": "$course_stats"}, {"$match": {"course_stats.course_id": course_id}}, + { + "$addFields": { + "course_stats.deleted_threads": { + "$ifNull": ["$course_stats.deleted_threads", 0] + }, + "course_stats.deleted_responses": { + "$ifNull": ["$course_stats.deleted_responses", 0] + }, + "course_stats.deleted_replies": { + "$ifNull": ["$course_stats.deleted_replies", 0] + }, + "course_stats.deleted_count": { + "$add": [ + {"$ifNull": ["$course_stats.deleted_threads", 0]}, + {"$ifNull": ["$course_stats.deleted_responses", 0]}, + {"$ifNull": ["$course_stats.deleted_replies", 0]}, + ] + }, + } + }, {"$sort": sort_criterion}, { "$facet": { @@ -1721,7 +2021,93 @@ def get_paginated_user_stats( @staticmethod def get_contents(**kwargs: Any) -> list[dict[str, Any]]: """Return contents.""" - return list(Contents().get_list(**kwargs)) + # Add soft delete filtering + kwargs["is_deleted"] = {"$ne": True} + contents = list(Contents().get_list(**kwargs)) + + # Get all thread IDs mentioned in comments + comment_thread_ids = set() + for content in contents: + if content.get("_type") == "Comment" and content.get("comment_thread_id"): + comment_thread_ids.add(content["comment_thread_id"]) + + return contents + + @staticmethod + def get_deleted_threads_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """Get deleted threads for a course. + + Args: + course_id: Course identifier + page: Page number for pagination + per_page: Number of items per page + author_id: Author username (despite the parameter name, this is actually the username) + """ + query = {"course_id": course_id, "is_deleted": True, "_type": "CommentThread"} + + if author_id: + query["author_username"] = author_id + + # Get total count + total_count = CommentThread().count_documents(query) + + # Get paginated results + skip = (page - 1) * per_page + threads = list( + CommentThread() + .find(query) + .skip(skip) + .limit(per_page) + .sort([("deleted_at", -1)]) + ) + + return { + "threads": threads, + "total_count": total_count, + "page": page, + "per_page": per_page, + } + + @staticmethod + def get_deleted_comments_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """Get deleted comments for a course. + + Args: + course_id: Course identifier + page: Page number for pagination + per_page: Number of items per page + author_id: Author username (despite the parameter name, this is actually the username) + """ + query = {"course_id": course_id, "is_deleted": True, "_type": "Comment"} + + if author_id: + query["author_username"] = author_id + + # Get total count + total_count = Comment().count_documents(query) + + # Get paginated results + skip = (page - 1) * per_page + comments = list( + Comment().find(query).skip(skip).limit(per_page).sort([("deleted_at", -1)]) + ) + + return { + "comments": comments, + "total_count": total_count, + "page": page, + "per_page": per_page, + } @staticmethod def get_user_thread_filter(course_id: str) -> dict[str, Any]: @@ -1763,3 +2149,487 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: CommentThread().find({"author_username": username}) ) return contents + + # AI Moderation Methods for MongoDB + @staticmethod + def flag_content_as_spam(content_type: str, content_id: str) -> int: + """ + Flag content as spam by adding AI system to abuse flaggers and updating spam fields. + + Args: + content_type: Type of content ('CommentThread' or 'Comment') + content_id: ID of the content to flag + + Returns: + Number of documents modified + """ + model = CommentThread() if content_type == "CommentThread" else Comment() + + # Get current content to check existing flaggers + content = model.get(content_id) + if not content: + return 0 + + return model.update(content_id, is_spam=True) + + @staticmethod + def unflag_content_as_spam(content_type: str, content_id: str) -> int: + """ + Remove spam flag from content. + + Args: + content_type: Type of content ('CommentThread' or 'Comment') + content_id: ID of the content to unflag + + Returns: + Number of documents modified + """ + model = CommentThread() if content_type == "CommentThread" else Comment() + + # Get current content to update flaggers + content = model.get(content_id) + if not content: + return 0 + + return model.update(content_id, is_spam=False) + + @classmethod + def mute_user( + cls, + muted_user_id: str, + muter_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + requester_is_privileged: bool = False, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Mute a user using MySQL models (same implementation as MySQL backend). + + Args: + muted_user_id: ID of user to mute + muter_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + requester_is_privileged: Whether requester has course-level privileges + + Returns: + Dictionary containing mute record data + """ + + @cls._handle_mute_errors + def _mute_operation() -> dict[str, Any]: + muted_user = User.objects.get(pk=int(muted_user_id)) + muted_by_user = User.objects.get(pk=int(muter_id)) + + # Basic validations + if muted_user.pk == muted_by_user.pk: + raise ValueError("Users cannot mute themselves") + + # Check if user being muted has discussion moderation privileges in this course + # (Global staff and discussion moderators cannot be muted, but course staff can be) + if cls.user_has_privileges(muted_user, course_id): + raise ValueError( + "Discussion moderators and global staff cannot be muted" + ) + + # Check if requester has privileges (global check is fine for requester) + is_privileged = requester_is_privileged or cls.user_has_privileges( + muted_by_user + ) + if scope == DiscussionMuteRecord.Scope.COURSE and not is_privileged: + raise ValueError("Only privileged users can create course-wide mutes") + + # Check existing mute + existing_query = DiscussionMuteRecord.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + if scope == DiscussionMuteRecord.Scope.PERSONAL: + existing_query = existing_query.filter(muted_by=muted_by_user) + + if existing_query.exists(): + raise ValueError("User is already muted in this scope") + + # Create mute record + mute = DiscussionMuteRecord( + muted_user=muted_user, + muted_by=muted_by_user, + course_id=course_id, + scope=scope, + reason=reason, + ) + mute.full_clean() + mute.save() + + # Create audit log + cls._create_audit_log( + "mute", + muted_user_id, + course_id, + muted_user, + muted_by_user, + reason, + scope=scope, + ) + return mute.to_dict() + + return _mute_operation() + + @classmethod + def unmute_user( + cls, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muter_id: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Unmute a user using MySQL models (same implementation as MySQL 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') + muter_id: Original muter ID (for personal unmutes) + + Returns: + Dictionary containing unmute result + """ + + @cls._handle_mute_errors + def _unmute_operation() -> dict[str, Any]: + muted_user = User.objects.get(pk=int(muted_user_id)) + unmuted_by_user = User.objects.get(pk=int(unmuted_by_id)) + + if hasattr(cls, "user_has_privileges"): + requester_is_privileged = cls.user_has_privileges( + unmuted_by_user, + **kwargs, + ) + else: + requester_is_privileged = kwargs.get( + "requester_is_privileged", False + ) or getattr(unmuted_by_user, "is_staff", False) + + # Find active mute + mute_query = DiscussionMuteRecord.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + if scope == DiscussionMuteRecord.Scope.PERSONAL and muter_id: + muted_by_user = User.objects.get(pk=int(muter_id)) + mute_query = mute_query.filter(muted_by=muted_by_user) + + mute = mute_query.first() + if not mute: + raise ValueError("No active mute found") + + # Permission checks + if ( + scope == DiscussionMuteRecord.Scope.COURSE + and not requester_is_privileged + ): + raise ValueError("Only privileged users can unmute course-wide mutes") + + if ( + scope == DiscussionMuteRecord.Scope.PERSONAL + and mute.muted_by.pk != unmuted_by_user.pk + ): + raise ValueError("Only the original muter can unmute a personal mute") + + # Perform unmute + mute.is_active = False + mute.unmuted_by = unmuted_by_user # type: ignore[assignment] + mute.unmuted_at = now() + mute.save() + + # Create audit log + cls._create_audit_log( + "unmute", + muted_user_id, + course_id, + muted_user, + unmuted_by_user, + scope=scope, + ) + + 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, + } + + return _unmute_operation() + + @classmethod + def mute_and_report_user( + cls, + muted_user_id: str, + muter_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 + muter_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 + """ + + @cls._handle_mute_errors + def _mute_and_report_operation() -> dict[str, Any]: + # Mute the user first + mute_result = cls.mute_user( + muted_user_id=muted_user_id, + muter_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + ) + + # Create additional audit log for the reporting aspect + try: + muter = User.objects.get(id=muter_id) + muted_user = User.objects.get(id=muted_user_id) + cls._create_audit_log( + "mute_and_report", + muted_user_id, + course_id, + muted_user, + muter, + reason, + reported=True, + mute_id=str(mute_result.get("_id", mute_result.get("id"))), + ) + except Exception: # pylint: disable=broad-exception-caught + # Don't fail the operation due to audit log issues + pass + + # Add reporting flags + mute_result["reported"] = True + mute_result["action"] = "mute_and_report" + return mute_result + + return _mute_and_report_operation() + + @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 MySQL models (same as MySQL 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 + """ + + @cls._handle_mute_errors + def _get_status_operation() -> dict[str, Any]: + user = User.objects.get(pk=int(muted_user_id)) + + # Optimize: Use single query to get all active mutes for this user in this course + mutes_query = DiscussionMuteRecord.objects.filter( + muted_user=user, + course_id=course_id, + is_active=True, + ) + + # Filter personal mutes if requesting_user_id is provided + if requesting_user_id: + mutes_query = mutes_query.filter( + Q(scope=DiscussionMuteRecord.Scope.COURSE) + | Q( + scope=DiscussionMuteRecord.Scope.PERSONAL, + muted_by__pk=int(requesting_user_id), + ) + ) + else: + # If no requesting_user_id, only return course-wide mutes + mutes_query = mutes_query.filter( + scope=DiscussionMuteRecord.Scope.COURSE + ) + + # Execute single query and separate by scope + all_mutes = list(mutes_query) + personal_mutes = [ + m for m in all_mutes if m.scope == DiscussionMuteRecord.Scope.PERSONAL + ] + course_mutes = [ + m for m in all_mutes if m.scope == DiscussionMuteRecord.Scope.COURSE + ] + + return { + "user_id": muted_user_id, + "course_id": course_id, + "is_muted": len(all_mutes) > 0, + "personal_mute": len(personal_mutes) > 0, + "course_mute": len(course_mutes) > 0, + "mute_details": [mute.to_dict() for mute in all_mutes], + } + + return _get_status_operation() + + @classmethod + def get_all_muted_users_for_course( + cls, + course_id: str, + requester_id: Optional[str] = None, + scope: str = "all", + requester_is_privileged: bool = False, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Get all muted users in a course using MySQL models with preserved MongoDB filtering logic. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + requester_is_privileged: Whether requester has course-level privileges + + Returns: + Dictionary containing list of muted users based on requester permissions + + Authorization: + - Learners: Can only see their own personal mutes + - Staff: Can see course-wide mutes and all personal mutes + """ + try: + # Only verify privileges if the requester_is_privileged flag is False + if requester_id and not requester_is_privileged: + try: + requester = User.objects.get(pk=int(requester_id)) + requester_is_privileged = cls.user_has_privileges(requester) + except User.DoesNotExist: + # If requester user does not exist, treat as not privileged and continue. + # This prevents errors from breaking the mute listing for non-existent users. + pass + + query = DiscussionMuteRecord.objects.filter( + course_id=course_id, is_active=True + ) + + # Apply scope-based filtering based on requester role + if requester_is_privileged: + # Privileged users can see all mutes based on scope requested + if scope == "personal": + # Show only personal mutes + query = query.filter(scope=DiscussionMuteRecord.Scope.PERSONAL) + elif scope == "course": + # Show only course-wide mutes + query = query.filter(scope=DiscussionMuteRecord.Scope.COURSE) + # For "all" scope, show both personal and course mutes + else: + # Learners can only see their own personal mutes + if requester_id: + query = query.filter( + scope=DiscussionMuteRecord.Scope.PERSONAL, + muted_by__pk=int(requester_id), + ) + else: + query = query.none() + + muted_users = [] + for mute in query.select_related("muted_user", "muted_by"): + # Convert to MongoDB-compatible structure to preserve API compatibility + mute_data = mute.to_dict() + muted_users.append(mute_data) + + return { + "course_id": course_id, + "requester_id": requester_id, + "scope_filter": scope, + "total_count": len(muted_users), + "muted_users": muted_users, + } + + 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 MySQL models (preserving MongoDB filtering logic). + + 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: + # Base query + query = DiscussionMuteRecord.objects.filter( + course_id=course_id, muted_by__pk=int(moderator_id) + ) + + # Apply scope filter (preserving MongoDB logic) + if scope != "all": + if scope == "personal": + query = query.filter(scope=DiscussionMuteRecord.Scope.PERSONAL) + elif scope == "course": + query = query.filter(scope=DiscussionMuteRecord.Scope.COURSE) + + # Apply active filter + if active_only: + query = query.filter(is_active=True) + + # Get results and convert to expected format (preserving MongoDB structure) + result = [] + for mute_record in query.select_related("muted_user", "muted_by"): + mute_dict = mute_record.to_dict() + # Ensure MongoDB-compatible structure + result.append( + { + "muted_user_id": mute_dict.get("muted_user_id"), + "muter_id": mute_dict.get("muter_id"), + "course_id": mute_dict.get("course_id"), + "scope": mute_dict.get("scope"), + "is_active": mute_dict.get("is_active", True), + "created": mute_dict.get("created"), + "modified": mute_dict.get("modified"), + "muted_at": mute_dict.get("muted_at"), + "reason": mute_dict.get("reason", ""), + } + ) + + return result + + except User.DoesNotExist as e: + raise ForumV2RequestError(f"Moderator not found: {e}") from e + except Exception as e: + raise ForumV2RequestError(f"Failed to get muted users: {str(e)}") from e diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index a50563c2..527fc0b0 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -62,6 +62,9 @@ def doc_to_hash(cls, doc: dict[str, Any]) -> dict[str, Any]: "created_at": doc.get("created_at"), "updated_at": doc.get("updated_at"), "title": doc.get("title"), + "is_deleted": doc.get("is_deleted", False), + "deleted_at": doc.get("deleted_at"), + "deleted_by": doc.get("deleted_by"), } def insert( @@ -78,6 +81,7 @@ def insert( abuse_flaggers: Optional[list[str]] = None, historical_abuse_flaggers: Optional[list[str]] = None, visible: bool = True, + is_spam: bool = False, ) -> str: """ Inserts a new comment document into the database. @@ -94,7 +98,7 @@ def insert( abuse_flaggers (Optional[list[str]], optional): Users who flagged the comment. Defaults to None. historical_abuse_flaggers (Optional[list[str]], optional): Users historically flagged the comment. visible (bool, optional): Whether the comment is visible. Defaults to True. - + is_spam (bool, optional): Whether the comment has been flagged as spam by AI moderation. Defaults to False. Returns: str: The ID of the inserted document. """ @@ -104,6 +108,7 @@ def insert( "visible": visible, "abuse_flaggers": abuse_flaggers or [], "historical_abuse_flaggers": historical_abuse_flaggers or [], + "is_spam": is_spam, "parent_ids": [ObjectId(parent_id)] if parent_id else [], "at_position_list": [], "body": body, @@ -163,6 +168,10 @@ def update( edit_reason_code: Optional[str] = None, endorsement_user_id: Optional[str] = None, sk: Optional[str] = None, + is_spam: Optional[bool] = None, + is_deleted: Optional[bool] = None, + deleted_at: Optional[datetime] = None, + deleted_by: Optional[str] = None, ) -> int: """ Updates a comment document in the database. @@ -206,6 +215,10 @@ def update( ("depth", depth), ("closed", closed), ("sk", sk), + ("is_spam", is_spam), + ("is_deleted", is_deleted), + ("deleted_at", deleted_at), + ("deleted_by", deleted_by), ] update_data: dict[str, Any] = { field: value for field, value in fields if value is not None @@ -248,30 +261,49 @@ def update( return result.modified_count - def delete(self, _id: str) -> int: + def delete( # type: ignore[override] + self, _id: str, mode: str = "hard", deleted_by: Optional[str] = None + ) -> tuple[int, int]: """ Deletes a comment from the database based on the id. Args: _id: The ID of the comment. + mode: 'hard' for permanent deletion, 'soft' for marking as deleted. + deleted_by: User ID of who deleted the comment (used in soft delete). Returns: The number of comments deleted. """ comment = self.get(_id) if not comment: - return 0 + return 0, 0 parent_comment_id = comment.get("parent_id") child_comments_deleted_count = 0 if not parent_comment_id: - child_comments_deleted_count = self.delete_child_comments(_id) - - result = self._collection.delete_one({"_id": ObjectId(_id)}) - if parent_comment_id: - self.update_child_count_in_parent_comment(parent_comment_id, -1) + child_comments_deleted_count = self.delete_child_comments( + _id, mode=mode, deleted_by=deleted_by + ) - no_of_comments_delete = result.deleted_count + child_comments_deleted_count + if mode == "soft": + # Soft delete: mark as deleted + self.update( + _id, + is_deleted=True, + deleted_at=datetime.now(), + deleted_by=deleted_by, + ) + result_count = 1 + else: + # Hard delete: permanently remove + result = self._collection.delete_one({"_id": ObjectId(_id)}) + result_count = result.deleted_count + if mode == "hard": + if parent_comment_id: + self.update_child_count_in_parent_comment(parent_comment_id, -1) + + no_of_comments_delete = result_count + child_comments_deleted_count comment_thread_id = comment["comment_thread_id"] self.update_comment_count_in_comment_thread( @@ -283,37 +315,62 @@ def delete(self, _id: str) -> int: sender=self.__class__, comment_id=_id ) - return no_of_comments_delete + return result_count, child_comments_deleted_count def get_author_username(self, author_id: str) -> str | None: """Return username for the respective author_id(user_id)""" user = Users().get(author_id) return user.get("username") if user else None - def delete_child_comments(self, _id: str) -> int: + def delete_child_comments( + self, _id: str, mode: str = "hard", deleted_by: Optional[str] = None + ) -> int: """ Delete child comments from the database based on the id. Args: _id: The ID of the parent comment whose child comments will be deleted. + mode: 'hard' for permanent deletion, 'soft' for marking as deleted. + deleted_by: User ID of who deleted the comments (used in soft delete). Returns: The number of child comments deleted. """ - child_comments_to_delete = self.find({"parent_id": ObjectId(_id)}) + if mode == "soft": + child_comments_to_delete = self.find( + {"parent_id": ObjectId(_id), "is_deleted": {"$ne": True}} + ) + else: + child_comments_to_delete = self.find({"parent_id": ObjectId(_id)}) + child_comment_ids_to_delete = [ child_comment.get("_id") for child_comment in child_comments_to_delete ] - child_comments_deleted = self._collection.delete_many( - {"_id": {"$in": child_comment_ids_to_delete}} - ) + + if mode == "soft": + # Soft delete: mark all child comments as deleted + deleted_at = datetime.now() + for child_comment_id in child_comment_ids_to_delete: + self.update( + str(child_comment_id), + is_deleted=True, + deleted_at=deleted_at, + deleted_by=deleted_by, + ) + child_comments_deleted_count = len(child_comment_ids_to_delete) + else: + # Hard delete: permanently remove + child_comments_deleted = self._collection.delete_many( + {"_id": {"$in": child_comment_ids_to_delete}} + ) + child_comments_deleted_count = child_comments_deleted.deleted_count for child_comment_id in child_comment_ids_to_delete: get_handler_by_name("comment_deleted").send( sender=self.__class__, comment_id=child_comment_id ) - return child_comments_deleted.deleted_count + return child_comments_deleted_count def update_child_count_in_parent_comment(self, parent_id: str, count: int) -> None: """ @@ -363,3 +420,184 @@ def update_sk(self, _id: str, parent_id: Optional[str]) -> None: """Updates sk field.""" sk = self.get_sk(_id, parent_id) self.update(_id, sk=sk) + + def restore_comment( + self, comment_id: str, restored_by: Optional[str] = None + ) -> bool: + """ + Restores a soft-deleted comment by setting is_deleted=False and clearing deletion metadata. + Also updates thread comment count and user course stats. + + Args: + comment_id: The ID of the comment to restore + restored_by: The ID of the user performing the restoration (optional) + + Returns: + bool: True if comment was restored, False if not found + """ + + # Get the comment first to check if it exists and get metadata + comment = self.get(comment_id) + if not comment: + return False + + # Only restore if it's actually deleted + if not comment.get("is_deleted", False): + return True # Already restored + + update_data: dict[str, Any] = { + "is_deleted": False, + "deleted_at": None, + "deleted_by": None, + } + + if restored_by: + update_data["restored_by"] = restored_by + update_data["restored_at"] = datetime.now().isoformat() + + result = self._collection.update_one( + {"_id": ObjectId(comment_id)}, {"$set": update_data} + ) + + if result.matched_count > 0: + # Update thread comment count + comment_thread_id = comment.get("comment_thread_id") + if comment_thread_id: + # Count child comments that are not deleted + child_count = 0 + if not comment.get("parent_id"): # If this is a parent comment + for _ in self.find( + { + "parent_id": ObjectId(comment_id), + "is_deleted": {"$eq": False}, + } + ): + child_count += 1 + + # Increment comment count in thread (1 for this comment + its non-deleted children) + self.update_comment_count_in_comment_thread( + comment_thread_id, 1 + child_count + ) + + # Update user course stats + author_id = comment.get("author_id") + course_id = comment.get("course_id") + parent_comment_id = comment.get("parent_id") + + if author_id and course_id: + + # Check if comment is anonymous + if not (comment.get("anonymous") or comment.get("anonymous_to_peers")): + from forum.backends.mongodb.api import ( # pylint: disable=import-outside-toplevel + MongoBackend, + ) + + if parent_comment_id: + # This is a reply - increment replies count and decrement deleted_replies + MongoBackend.update_stats_for_course( + author_id, course_id, replies=1, deleted_replies=-1 + ) + else: + # This is a response - increment responses count, decrement deleted_responses + # Also increment replies by child count and decrement deleted_replies by child_count + MongoBackend.update_stats_for_course( + author_id, + course_id, + responses=1, + deleted_responses=-1, + replies=child_count, + deleted_replies=-child_count, + ) + + return True + + return False + + def get_user_deleted_comment_count( + self, user_id: str, course_ids: list[str] + ) -> int: + """ + Returns count of deleted comments for user in the given course_ids. + + Args: + user_id: The ID of the user + course_ids: List of course IDs to search in + + Returns: + int: Count of deleted comments + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": self.content_type, + "is_deleted": True, + } + return self._collection.count_documents(query_params) + + def restore_user_deleted_comments( + self, user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """ + Restores (undeletes) comments of user in the given course_ids by setting is_deleted=False. + + Args: + user_id: The ID of the user whose comments to restore + course_ids: List of course IDs to restore comments in + restored_by: The ID of the user performing the restoration (optional) + + Returns: + int: Number of comments restored + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": {"$eq": True}, + } + + comments_restored = 0 + comments = self.get_list(**query_params) + + for comment in comments: + comment_id = comment.get("_id") + if comment_id: + if self.restore_comment(str(comment_id), restored_by=restored_by): + comments_restored += 1 + + return comments_restored + + def delete_user_comments( + self, user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """ + Deletes (soft deletes) all non-deleted comments of user in the given course_ids. + + Args: + user_id: The ID of the user whose comments to delete + course_ids: List of course IDs to delete comments from + deleted_by: The ID of the user performing the deletion (optional) + + Returns: + int: Number of comments deleted + """ + from forum import api as forum_api # pylint: disable=import-outside-toplevel + + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": {"$ne": True}, + } + + comments_deleted = 0 + comments = self.get_list(**query_params) + + for comment in comments: + comment_id = comment.get("_id") + course_id = comment.get("course_id") + if comment_id: + # Use forum_api.delete_comment which supports deleted_by parameter + forum_api.delete_comment( + comment_id, course_id=course_id, deleted_by=deleted_by + ) + comments_deleted += 1 + + return comments_deleted diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index be8e9638..9b5da85f 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -81,6 +81,9 @@ def doc_to_hash(cls, doc: dict[str, Any]) -> dict[str, Any]: "author_id": doc.get("author_id"), "group_id": doc.get("group_id"), "thread_id": str(doc.get("_id")), + "is_deleted": doc.get("is_deleted", False), + "deleted_at": doc.get("deleted_at"), + "deleted_by": doc.get("deleted_by"), } def insert( @@ -100,6 +103,7 @@ def insert( abuse_flaggers: Optional[list[str]] = None, historical_abuse_flaggers: Optional[list[str]] = None, group_id: Optional[int] = None, + is_spam: bool = False, ) -> str: """ Inserts a new thread document into the database. @@ -119,7 +123,7 @@ def insert( visible (bool): Whether the thread is visible. Defaults to True. abuse_flaggers: A list of users who flagged the thread for abuse. historical_abuse_flaggers: A list of users who historically flagged the thread for abuse. - + is_spam: Whether the thread was flagged as spam by AI moderation. Defaults to False. Raises: ValueError: If `thread_type` is not 'question' or 'discussion'. ValueError: If `context` is not 'course' or 'standalone'. @@ -162,6 +166,7 @@ def insert( "visible": visible, "abuse_flaggers": abuse_flaggers, "historical_abuse_flaggers": historical_abuse_flaggers, + "is_spam": is_spam, } if group_id: thread_data["group_id"] = group_id @@ -205,6 +210,10 @@ def update( closed_by_id: Optional[str] = None, group_id: Optional[int] = None, skip_timestamp_update: bool = False, + is_spam: Optional[bool] = None, + is_deleted: Optional[bool] = None, + deleted_at: Optional[datetime] = None, + deleted_by: Optional[str] = None, ) -> int: """ Updates a thread document in the database. @@ -258,6 +267,10 @@ def update( ("close_reason_code", close_reason_code), ("closed_by_id", closed_by_id), ("group_id", group_id), + ("is_spam", is_spam), + ("is_deleted", is_deleted), + ("deleted_at", deleted_at), + ("deleted_by", deleted_by), ] update_data: dict[str, Any] = { field: value for field, value in fields if value is not None @@ -297,3 +310,150 @@ def get_author_username(self, author_id: str) -> str | None: """Return username for the respective author_id(user_id)""" user = Users().get(author_id) return user.get("username") if user else None + + def restore_thread(self, thread_id: str, restored_by: Optional[str] = None) -> bool: + """ + Restores a soft-deleted thread by setting is_deleted=False and clearing deletion metadata. + Also restores all soft-deleted comments in the thread and updates user course stats. + + Args: + thread_id: The ID of the thread to restore + restored_by: The ID of the user performing the restoration (optional) + + Returns: + bool: True if thread was restored, False if not found + """ + + # Get the thread first to check if it exists and get metadata + thread = self.get(thread_id) + if not thread: + return False + + # Only restore if it's actually deleted + if not thread.get("is_deleted", False): + return True # Already restored + + update_data: dict[str, Any] = { + "is_deleted": False, + "deleted_at": None, + "deleted_by": None, + } + + if restored_by: + update_data["restored_by"] = restored_by + update_data["restored_at"] = datetime.now().isoformat() + + result = self._collection.update_one( + {"_id": ObjectId(thread_id)}, {"$set": update_data} + ) + + if result.matched_count > 0: + # Update user course stats for the thread itself + author_id = thread.get("author_id") + course_id = thread.get("course_id") + + if author_id and course_id: + + # Check if thread is anonymous + if not (thread.get("anonymous") or thread.get("anonymous_to_peers")): + from forum.backends.mongodb.api import ( # pylint: disable=import-outside-toplevel + MongoBackend, + ) + + # Increment threads count and decrement deleted_threads count in user stats + MongoBackend.update_stats_for_course( + author_id, course_id, threads=1, deleted_threads=-1 + ) + + return True + + return False + + def get_user_deleted_threads_count( + self, user_id: str, course_ids: list[str] + ) -> int: + """ + Returns count of deleted threads for user in the given course_ids. + + Args: + user_id: The ID of the user + course_ids: List of course IDs to search in + + Returns: + int: Count of deleted threads + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": self.content_type, + "is_deleted": True, + } + return self._collection.count_documents(query_params) + + def restore_user_deleted_threads( + self, user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """ + Restores (undeletes) threads of user in the given course_ids by setting is_deleted=False. + + Args: + user_id: The ID of the user whose threads to restore + course_ids: List of course IDs to restore threads in + restored_by: The ID of the user performing the restoration (optional) + + Returns: + int: Number of threads restored + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": True, + } + + threads_restored = 0 + threads = self.get_list(**query_params) + + for thread in threads: + thread_id = thread.get("_id") + if thread_id: + if self.restore_thread(str(thread_id), restored_by=restored_by): + threads_restored += 1 + + return threads_restored + + def delete_user_threads( + self, user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """ + Deletes (soft deletes) all non-deleted threads of user in the given course_ids. + + Args: + user_id: The ID of the user whose threads to delete + course_ids: List of course IDs to delete threads from + deleted_by: The ID of the user performing the deletion (optional) + + Returns: + int: Number of threads deleted + """ + from forum import api as forum_api # pylint: disable=import-outside-toplevel + + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "is_deleted": {"$ne": True}, + } + + threads_deleted = 0 + threads = self.get_list(**query_params) + + for thread in threads: + thread_id = thread.get("_id") + course_id = thread.get("course_id") + if thread_id: + # Use the soft delete from forum API, which handles comments and stats + forum_api.delete_thread( + thread_id, course_id=course_id, deleted_by=deleted_by + ) + threads_deleted += 1 + + return threads_deleted diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 93591ac8..b8d3b070 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1,17 +1,18 @@ """Client backend for forum v2.""" +import datetime as dt import math import random -from datetime import timedelta -from typing import Any, Optional, Union +from functools import wraps +from typing import Any, Callable, Dict, Optional, TypeVar, 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.exceptions import ObjectDoesNotExist, ValidationError from django.core.paginator import Paginator from django.db.models import ( - Count, Case, + Count, Exists, F, IntegerField, @@ -19,8 +20,8 @@ OuterRef, Q, Subquery, - When, Sum, + When, ) from django.utils import timezone from rest_framework import status @@ -32,10 +33,12 @@ Comment, CommentThread, CourseStat, + DiscussionMuteRecord, EditHistory, ForumUser, HistoricalAbuseFlagger, LastReadTime, + ModerationAuditLog, ReadState, Subscription, UserVote, @@ -43,10 +46,43 @@ from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import get_group_ids_from_params +FuncType = TypeVar("FuncType", bound=Callable[..., Any]) + class MySQLBackend(AbstractBackend): """MySQL backend api.""" + @staticmethod + def _handle_mute_errors(func: FuncType) -> FuncType: + """Simple decorator for mute operation error handling.""" + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except User.DoesNotExist as e: + raise ValueError(f"User not found: {e}") from e + except ValidationError as ve: + raise ValueError(f"Validation error: {ve}") from ve + except Exception as e: + operation = func.__name__.replace("_", " ") + raise ValueError(f"Failed to {operation}: {e}") from e + + return wrapper # type: ignore + + @classmethod + def _validate_mute_users( + cls, muted_user_id: str, muter_id: str + ) -> tuple[User, User]: + """Validate and return muted and muter users.""" + muted_user = User.objects.get(pk=int(muted_user_id)) + muted_by_user = User.objects.get(pk=int(muter_id)) + + if muted_user.pk == muted_by_user.pk: + raise ValidationError("Users cannot mute themselves") + + return muted_user, muted_by_user + @classmethod def update_stats_for_course( cls, user_id: str, course_id: str, **kwargs: Any @@ -62,6 +98,9 @@ def update_stats_for_course( course_stat.threads = 0 course_stat.responses = 0 course_stat.replies = 0 + course_stat.deleted_threads = 0 + course_stat.deleted_responses = 0 + course_stat.deleted_replies = 0 for key, value in kwargs.items(): if hasattr(course_stat, key): @@ -83,6 +122,86 @@ def _get_entity_from_type( except ObjectDoesNotExist: return None + @staticmethod + def user_has_privileges(user: object, course_id: Optional[str] = None) -> bool: + """Check if user has any privileges (forum roles or course access). + + Args: + user: User object to check + course_id: Optional course ID to check privileges for specific course. + + Returns: + True if user has any privileges + """ + # Global Django staff + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + # Check for any forum role or course access role + if course_id: + if ( + hasattr(user, "role_set") + and user.role_set.filter(course_id=course_id).exists() + ): + return True + if ( + hasattr(user, "courseaccessrole_set") + and user.courseaccessrole_set.filter(course_id=course_id).exists() + ): + return True + return False + + # Check if user has any role across all courses + return ( + hasattr(user, "role_set") + and user.role_set.exists() + or hasattr(user, "courseaccessrole_set") + and user.courseaccessrole_set.exists() + ) + + @staticmethod + def user_has_moderation_privileges( + user: object, course_id: Optional[str] = None + ) -> bool: + """Check if user has discussion moderation privileges. + + Returns True only for: + - Global staff (is_staff or is_superuser) + - Discussion moderators, administrators, and community TAs + + Course staff return False. + + Args: + user: User object to check + course_id: Optional course ID to check for specific course + + Returns: + True if user has discussion moderation privileges + """ + # Global staff + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + # Check for discussion moderation roles only + if hasattr(user, "role_set"): + protected_roles = { + "Moderator", + "Administrator", + "Community TA", + "Group Moderator", + } + + if course_id: + role_names = user.role_set.filter(course_id=course_id).values_list( + "name", flat=True + ) + else: + role_names = user.role_set.values_list("name", flat=True) + + return any(role in protected_roles for role in role_names) + + return False + @classmethod def flag_as_abuse( cls, user_id: str, entity_id: str, **kwargs: Any @@ -394,6 +513,7 @@ def handle_pin_unpin_thread_request( def get_abuse_flagged_count(thread_ids: list[str]) -> dict[str, int]: """ Retrieves the count of abuse-flagged comments for each thread in the provided list of thread IDs. + Only counts non-deleted comments. Args: thread_ids (list[str]): List of thread IDs to check for abuse flags. @@ -414,6 +534,7 @@ def get_abuse_flagged_count(thread_ids: list[str]) -> dict[str, int]: abuse_flagged_comments = ( Comment.objects.filter( comment_thread__pk__in=thread_ids, + is_deleted=False, ) .annotate( abuse_flaggers_count=Subquery( @@ -608,6 +729,7 @@ def handle_threads_query( raw_query: bool = False, commentable_ids: Optional[list[str]] = None, is_moderator: bool = False, + is_deleted: bool = False, ) -> dict[str, Any]: """ Handles complex thread queries based on various filters and returns paginated results. @@ -653,7 +775,7 @@ def handle_threads_query( raise ValueError("User does not exist") from exc # Base query base_query = CommentThread.objects.filter( - pk__in=mysql_comment_thread_ids, context=context + pk__in=mysql_comment_thread_ids, context=context, is_deleted=is_deleted ) # Group filtering @@ -986,6 +1108,34 @@ def delete_comments_of_a_thread(thread_id: str) -> None: """Delete comments of a thread.""" Comment.objects.filter(comment_thread__pk=thread_id, parent=None).delete() + @staticmethod + def soft_delete_comments_of_a_thread( + thread_id: str, deleted_by: Optional[str] = None + ) -> tuple[int, int]: + """Soft delete comments of a thread by marking them as deleted. + + Returns: + tuple: (responses_deleted, replies_deleted) + """ + count_of_replies_deleted = 0 + # Only soft-delete responses (parent comments) that aren't already deleted + count_of_response_deleted = Comment.objects.filter( + comment_thread__pk=thread_id, + parent=None, + is_deleted=False, # Only update non-deleted comments + ).update(is_deleted=True, deleted_at=timezone.now(), deleted_by=deleted_by) + + # Soft-delete child comments (replies) of each response + for comment in Comment.objects.filter( + comment_thread__pk=thread_id, parent=None, is_deleted=True + ): + child_comments = Comment.objects.filter(parent=comment, is_deleted=False) + count_of_replies_deleted += child_comments.update( + is_deleted=True, deleted_at=timezone.now(), deleted_by=deleted_by + ) + + return count_of_response_deleted, count_of_replies_deleted + @classmethod def delete_subscriptions_of_a_thread(cls, thread_id: str) -> None: """Delete subscriptions of a thread.""" @@ -1081,8 +1231,10 @@ def get_threads( params.get("sort_key", ""), int(params.get("page", 1)), int(params.get("per_page", 100)), + context=params.get("context", "course"), commentable_ids=params.get("commentable_ids", []), is_moderator=params.get("is_moderator", False), + is_deleted=params.get("is_deleted", False), ) context: dict[str, Any] = { "count_flagged": count_flagged, @@ -1098,7 +1250,9 @@ def get_threads( return threads @classmethod - def get_user_voted_ids(cls, user_id: str, vote: str) -> list[str]: + def get_user_voted_ids( + cls, user_id: str, vote: str, course_id: Optional[str] = None + ) -> list[str]: """Get the IDs of the posts voted by a user.""" if vote not in ["up", "down"]: raise ValueError("Invalid vote type") @@ -1205,7 +1359,7 @@ def replace_username(user_id: str, username: str) -> None: try: user = User.objects.get(id=user_id) user.username = username - user.save() + user.save(update_fields=["username"]) except User.DoesNotExist as exc: raise ValueError("User does not exist") from exc @@ -1369,14 +1523,22 @@ def build_course_stats(cls, author_id: str, course_id: str) -> None: comments_updated_at = comments.aggregate(Max("updated_at"))["updated_at__max"] updated_at = max( - threads_updated_at or timezone.now() - timedelta(days=365 * 100), - comments_updated_at or timezone.now() - timedelta(days=365 * 100), + threads_updated_at or timezone.now() - dt.timedelta(days=365 * 100), + comments_updated_at or timezone.now() - dt.timedelta(days=365 * 100), ) + # Count deleted content + deleted_threads = threads.filter(is_deleted=True).count() + deleted_responses = responses.filter(is_deleted=True).count() + deleted_replies = replies.filter(is_deleted=True).count() + stats, _ = CourseStat.objects.get_or_create(user=author, course_id=course_id) - stats.threads = threads.count() - stats.responses = responses.count() - stats.replies = replies.count() + stats.threads = threads.count() - deleted_threads + stats.responses = responses.count() - deleted_responses + stats.replies = replies.count() - deleted_replies + stats.deleted_threads = deleted_threads + stats.deleted_responses = deleted_responses + stats.deleted_replies = deleted_replies stats.active_flags = active_flags stats.inactive_flags = inactive_flags stats.last_activity_at = updated_at @@ -1450,7 +1612,9 @@ def find_or_create_user( def get_comment(comment_id: str) -> dict[str, Any] | None: """Return comment from comment_id.""" try: - comment = Comment.objects.get(pk=comment_id) + comment = Comment.objects.get( + pk=comment_id, is_deleted=False + ) # Exclude soft deleted comments except Comment.DoesNotExist: return None return comment.to_dict() @@ -1530,6 +1694,324 @@ def delete_comment(cls, comment_id: str) -> None: comment.delete() + @staticmethod + def soft_delete_comment( + comment_id: str, deleted_by: Optional[str] = None + ) -> tuple[int, int]: + """Soft delete comment by marking it as deleted. + + Returns: + tuple: (responses_deleted, replies_deleted) + """ + comment = Comment.objects.get(pk=comment_id) + deleted_user: Optional[User] = None + if deleted_by: + try: + deleted_user = User.objects.get(pk=int(deleted_by)) + except (User.DoesNotExist, ValueError): + deleted_user = None + + # If this is a reply (has a parent) -> mark reply deleted + # Note: We don't decrement child_count on soft delete (matches MongoDB behavior) + if comment.parent: + comment.is_deleted = True + comment.deleted_at = timezone.now() + comment.deleted_by = deleted_user # type: ignore[assignment] + comment.save() + # replies_deleted = 1 (one reply), responses_deleted = 0 + return 0, 1 + + # Else: this is a parent/response comment. Soft-delete it and all its undeleted children. + # Mark parent deleted + comment.is_deleted = True + comment.deleted_at = timezone.now() + comment.deleted_by = deleted_user # type: ignore[assignment] + comment.save() + + # Soft-delete child replies that are not already deleted + child_qs = Comment.objects.filter(parent=comment, is_deleted=False) + replies_deleted = 0 + if child_qs.exists(): + replies_deleted = child_qs.update( + is_deleted=True, + deleted_at=timezone.now(), + deleted_by=deleted_user, + ) + # responses_deleted = 1 (the parent), replies_deleted = number updated + return 1, int(replies_deleted) + + @classmethod + def restore_comment( + cls, + comment_id: str, + restored_by: Optional[str] = None, # pylint: disable=unused-argument + ) -> bool: + """Restore a soft-deleted comment and update stats.""" + try: + comment = Comment.objects.get(pk=comment_id, is_deleted=True) + + # Get comment metadata before restoring + author_id = str(comment.author.pk) + course_id = comment.course_id + is_reply = comment.parent is not None + is_anonymous = comment.anonymous or comment.anonymous_to_peers + + # Restore the comment + comment.is_deleted = False + comment.deleted_at = None + comment.deleted_by = None # type: ignore[assignment] + comment.save() + + # Update user course stats (only if not anonymous) + if not is_anonymous: + if is_reply: + # This is a reply - increment replies, decrement deleted_replies + cls.update_stats_for_course( + author_id, course_id, replies=1, deleted_replies=-1 + ) + else: + # This is a response - increment responses, decrement deleted_responses + # Count ONLY children that are STILL DELETED (not already restored separately) + deleted_child_count = Comment.objects.filter( + parent=comment, is_deleted=True + ).count() + + cls.update_stats_for_course( + author_id, + course_id, + responses=1, + deleted_responses=-1, + replies=deleted_child_count, + deleted_replies=-deleted_child_count, + ) + + return True + except ObjectDoesNotExist: + return False + + @classmethod + def restore_thread( + cls, + thread_id: str, + restored_by: Optional[str] = None, # pylint: disable=unused-argument + ) -> bool: + """Restore a soft-deleted thread and update stats.""" + try: + thread = CommentThread.objects.get(pk=thread_id, is_deleted=True) + + # Get thread metadata before restoring + author_id = str(thread.author.pk) + course_id = thread.course_id + is_anonymous = thread.anonymous or thread.anonymous_to_peers + + # Restore the thread + thread.is_deleted = False + thread.deleted_at = None + thread.deleted_by = None # type: ignore[assignment] + thread.save() + + # Update user course stats (only if not anonymous) + if not is_anonymous: + cls.update_stats_for_course( + author_id, course_id, threads=1, deleted_threads=-1 + ) + + return True + except ObjectDoesNotExist: + return False + + @classmethod + def restore_user_deleted_comments( + cls, user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """Restore all deleted comments for a user in given courses and update stats.""" + # Get all deleted comments for this user + deleted_comments = Comment.objects.filter( + author_id=user_id, course_id__in=course_ids, is_deleted=True + ) + + count = 0 + + # IMPORTANT: Restore replies (children) FIRST, then responses (parents) + # This prevents double-counting replies when both parent and children are restored + + # First, restore all replies (comments with a parent) + replies = [c for c in deleted_comments if c.parent is not None] + for comment in replies: + if cls.restore_comment(str(comment.pk), restored_by=restored_by): + count += 1 + + # Then, restore all responses (comments without a parent) + responses = [c for c in deleted_comments if c.parent is None] + for comment in responses: + if cls.restore_comment(str(comment.pk), restored_by=restored_by): + count += 1 + + return count + + @classmethod + def restore_user_deleted_threads( + cls, user_id: str, course_ids: list[str], restored_by: Optional[str] = None + ) -> int: + """Restore all deleted threads for a user in given courses and update stats.""" + # Get all deleted threads for this user + deleted_threads = CommentThread.objects.filter( + author_id=user_id, course_id__in=course_ids, is_deleted=True + ) + + count = 0 + # Restore each thread individually to properly update stats + for thread in deleted_threads: + if cls.restore_thread(str(thread.pk), restored_by=restored_by): + count += 1 + + return count + + @classmethod + def get_user_threads_count(cls, user_id: str, course_ids: list[str]) -> int: + """ + Returns the count of non-deleted threads for a user in the given + course_ids. + + Args: + user_id: The user ID whose threads to count + course_ids: List of course IDs to search within + + Returns: + int: Count of non-deleted threads + """ + return CommentThread.objects.filter( + author_id=user_id, course_id__in=course_ids, is_deleted=False + ).count() + + @classmethod + def get_user_comment_count(cls, user_id: str, course_ids: list[str]) -> int: + """ + Returns the count of non-deleted comments (responses and replies) + for a user in the given course_ids. + + Args: + user_id: The user ID whose comments to count + course_ids: List of course IDs to search within + + Returns: + int: Count of non-deleted comments + """ + return Comment.objects.filter( + author_id=user_id, course_id__in=course_ids, is_deleted=False + ).count() + + @classmethod + def delete_user_threads( + cls, user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """ + Soft deletes all non-deleted threads for a user in the given + course_ids. + + Args: + user_id: The user ID whose threads to delete + course_ids: List of course IDs to delete from + deleted_by: The user ID performing the deletion (for audit trail) + + Returns: + int: Number of threads deleted + """ + # Get all non-deleted threads for this user in the specified courses + threads = CommentThread.objects.filter( + author_id=user_id, course_id__in=course_ids, is_deleted=False + ) + + count = 0 + # Track affected (user_id, course_id) pairs for stats rebuild + affected_courses = set() + + # Delete each thread individually to properly handle stats and + # associated comments + for thread in threads: + # Soft delete all comments associated with this thread + cls.soft_delete_comments_of_a_thread(str(thread.pk), deleted_by) + + # Delete subscriptions for this thread + cls.delete_subscriptions_of_a_thread(str(thread.pk)) + + # Soft delete the thread itself + result = cls.soft_delete_thread(str(thread.pk), deleted_by) + if result: + count += 1 + + # Track course for stats rebuild if not anonymous + if not (thread.anonymous or thread.anonymous_to_peers): + affected_courses.add((user_id, thread.course_id)) + + # Rebuild stats once per affected course (more efficient than per-thread) + for affected_user_id, affected_course_id in affected_courses: + cls.build_course_stats(affected_user_id, affected_course_id) + + return count + + @classmethod + def delete_user_comments( + cls, user_id: str, course_ids: list[str], deleted_by: Optional[str] = None + ) -> int: + """ + Soft deletes all non-deleted comments for a user in the given + course_ids. + + Args: + user_id: The user ID whose comments to delete + course_ids: List of course IDs to delete from + deleted_by: The user ID performing the deletion (for audit trail) + + Returns: + int: Number of comments deleted (responses + replies) + """ + # Delete replies first, then responses to avoid processing + # already-deleted child comments (since deleting a parent also deletes children) + count = 0 + # Track affected (user_id, course_id) pairs for stats rebuild + affected_courses = set() + + # First, delete all replies (comments with a parent) + replies = Comment.objects.filter( + author_id=user_id, + course_id__in=course_ids, + is_deleted=False, + parent__isnull=False, + ) + for reply in replies: + responses_deleted, replies_deleted = cls.soft_delete_comment( + str(reply.pk), deleted_by + ) + count += responses_deleted + replies_deleted + + # Track course for stats rebuild if not anonymous + if not (reply.anonymous or reply.anonymous_to_peers): + affected_courses.add((user_id, reply.course_id)) + + # Then, delete all responses (comments without a parent) + responses = Comment.objects.filter( + author_id=user_id, + course_id__in=course_ids, + is_deleted=False, + parent__isnull=True, + ) + for response in responses: + responses_deleted, replies_deleted = cls.soft_delete_comment( + str(response.pk), deleted_by + ) + count += responses_deleted + replies_deleted + + # Track course for stats rebuild if not anonymous + if not (response.anonymous or response.anonymous_to_peers): + affected_courses.add((user_id, response.course_id)) + + # Rebuild stats once per affected course (more efficient than per-comment) + for affected_user_id, affected_course_id in affected_courses: + cls.build_course_stats(affected_user_id, affected_course_id) + + return count + @staticmethod def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: """Return commentables counts in a course based on thread's type.""" @@ -1686,6 +2168,9 @@ def update_comment(comment_id: str, **kwargs: Any) -> int: vote=-1, ) + if "is_spam" in kwargs: + comment.is_spam = kwargs["is_spam"] + comment.updated_at = timezone.now() comment.save() return 1 @@ -1768,6 +2253,20 @@ def delete_thread(thread_id: str) -> int: thread.delete() return 1 + @staticmethod + def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: + """Soft delete thread by marking it as deleted.""" + try: + thread = CommentThread.objects.get(pk=thread_id) + except ObjectDoesNotExist: + return 0 + thread.is_deleted = True + thread.deleted_at = timezone.now() + if deleted_by: + thread.deleted_by = User.objects.get(pk=int(deleted_by)) + thread.save() + return 1 + @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1898,6 +2397,9 @@ def update_thread( vote=-1, ) + if "is_spam" in kwargs: + thread.is_spam = kwargs["is_spam"] + thread.updated_at = timezone.now() thread.save() return 1 @@ -1905,14 +2407,19 @@ def update_thread( @staticmethod def get_user_thread_filter(course_id: str) -> dict[str, Any]: """Get user thread filter""" - return {"course_id": course_id} + return { + "course_id": course_id, + "is_deleted": False, + } # Exclude soft deleted threads @staticmethod def get_filtered_threads( query: dict[str, Any], ids_only: bool = False ) -> list[dict[str, Any]]: """Return a list of threads that match the given filter.""" - threads = CommentThread.objects.filter(**query) + threads = CommentThread.objects.filter(**query).filter( + is_deleted=False + ) # Exclude soft deleted threads if ids_only: return [{"_id": str(thread.pk)} for thread in threads] return [thread.to_dict() for thread in threads] @@ -1932,17 +2439,22 @@ def update_user(user_id: str, data: dict[str, Any]) -> int: except ObjectDoesNotExist: return 0 + user_fields_to_update = [] if "username" in data: user.username = data["username"] - if "email" in data: - user.email = data["email"] + user_fields_to_update.append("username") + # MySQL backend does not update auth_user.email (LMS manages it) if "default_sort_key" in data: forum_user.default_sort_key = data["default_sort_key"] if "read_states" in data and data["read_states"] == []: user_read_states = ReadState.objects.filter(user=user) + # Delete LastReadTime records first to avoid FK constraint violations + LastReadTime.objects.filter(read_state__in=user_read_states).delete() + # Then delete ReadState records user_read_states.delete() - user.save() + if user_fields_to_update: + user.save(update_fields=user_fields_to_update) forum_user.save() return 1 @@ -1952,7 +2464,7 @@ def replace_username_in_all_content(user_id: str, username: str) -> None: try: user = User.objects.get(pk=user_id) user.username = username - user.save() + user.save(update_fields=["username"]) # Update author_username in all content Comment.objects.filter(author=user).update(author_username=username) @@ -2101,6 +2613,12 @@ def get_user_sort_criterion(sort_by: str) -> dict[str, Any]: } elif sort_by == "recency": return {"course_stats__last_activity_at": -1, "username": -1} + elif sort_by == "deleted": + # Sort by total deleted count (sum of threads + responses + replies) + return { + "deleted_count": -1, + "username": -1, + } else: return { "course_stats__threads": -1, @@ -2114,10 +2632,20 @@ def get_paginated_user_stats( cls, course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] ) -> dict[str, Any]: """Get paginated user stats.""" - users = User.objects.filter( + users_query = User.objects.filter( Q(course_stats__course_id=course_id) & Q(course_stats__course_id__isnull=False) - ).order_by( + ) + + # If sorting by deleted_count, annotate with computed field + if "deleted_count" in sort_criterion: + users_query = users_query.annotate( + deleted_count=F("course_stats__deleted_threads") + + F("course_stats__deleted_responses") + + F("course_stats__deleted_replies") + ) + + users = users_query.order_by( *[f"-{key}" for key, value in sort_criterion.items() if value == -1], *[key for key, value in sort_criterion.items() if value == 1], ) @@ -2152,8 +2680,14 @@ def get_contents(**kwargs: Any) -> list[dict[str, Any]]: key: value for key, value in kwargs.items() if hasattr(CommentThread, key) } - comments = Comment.objects.filter(**comment_filters) - threads = CommentThread.objects.filter(**thread_filters) + comments = Comment.objects.filter(**comment_filters).filter( + is_deleted=False, # Exclude soft deleted comments + comment_thread__is_deleted=False, # Exclude comments on deleted threads + ) + # Exclude soft deleted threads + threads = CommentThread.objects.filter(**thread_filters).filter( + is_deleted=False + ) sort_key = kwargs.get("sort_key") if sort_key: @@ -2208,3 +2742,485 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: for thread in CommentThread.objects.filter(author__username=username) ] return contents + + # AI Moderation Methods for MySQL + @classmethod + def flag_content_as_spam(cls, content_type: str, content_id: str) -> int: + """ + Flag content as spam by adding AI system to abuse flaggers and updating spam fields. + + Args: + content_type: Type of content ('CommentThread' or 'Comment') + content_id: ID of the content to flag + + Returns: + Number of documents modified + """ + + # Use existing update methods to add AI system to abuse flaggers and set spam flag + update_data = {"is_spam": True} + if content_type == "CommentThread": + return cls.update_thread(content_id, **update_data) + else: + return cls.update_comment(content_id, **update_data) + + @classmethod + def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: + """ + Remove spam flag from content. + + Args: + content_type: Type of content ('CommentThread' or 'Comment') + content_id: ID of the content to unflag + + Returns: + Number of documents modified + """ + # Just update the spam flag to False + update_data = {"is_spam": False} + + if content_type == "CommentThread": + return cls.update_thread(content_id, **update_data) + else: + return cls.update_comment(content_id, **update_data) + + @staticmethod + def _create_audit_log( + action_type: str, + user_id: str, + course_id: str, + muted_user: Any, + muter_user: Any, + reason: str = "", + **extras: Any, + ) -> None: + """Create audit log entry for mute operations.""" + try: + ModerationAuditLog( + timestamp=dt.datetime.now(dt.timezone.utc), + body=f"User {action_type}: {user_id}", + classifier_output={ + "action_type": action_type, + "course_id": course_id, + "muted_user_id": user_id, + "backend": "mysql", + **extras, + }, + reasoning=reason or "No reason provided", + actions_taken=[f"user_{action_type}"], + original_author=muted_user, + moderator=muter_user, + ).save() + except Exception: # pylint: disable=broad-exception-caught + # Don't fail operations due to audit logging issues + pass + + # Mute/Unmute Methods for MySQL Backend + @classmethod + @_handle_mute_errors + def mute_user( + cls, + muted_user_id: str, + muter_id: str, + course_id: str, + scope: str = "personal", + reason: str = "", + requester_is_privileged: bool = False, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Mute a user in discussions. + + Args: + muted_user_id: ID of user to mute + muter_id: ID of user performing the mute + course_id: Course identifier + scope: Mute scope ('personal' or 'course') + reason: Optional reason for mute + requester_is_privileged: Whether requester has course-level privileges + + Returns: + Dictionary containing mute record data + """ + muted_user, muted_by_user = cls._validate_mute_users(muted_user_id, muter_id) + + # Check if user being muted has discussion moderation privileges + # Only moderators and global staff are protected - course staff can be muted + if cls.user_has_moderation_privileges(muted_user, course_id): + raise ValidationError( + "Discussion moderators and global staff cannot be muted" + ) + + # Check if requester has privileges (global check is fine for requester) + is_privileged = requester_is_privileged or cls.user_has_privileges( + muted_by_user + ) + if scope == DiscussionMuteRecord.Scope.COURSE and not is_privileged: + raise ValidationError("Only privileged users can create course-wide mutes") + + # Check existing mute + existing_query = DiscussionMuteRecord.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + if scope == DiscussionMuteRecord.Scope.PERSONAL: + existing_query = existing_query.filter(muted_by=muted_by_user) + + if existing_query.exists(): + raise ValidationError("User is already muted in this scope") + + # Create mute record + mute = DiscussionMuteRecord( + muted_user=muted_user, + muted_by=muted_by_user, + course_id=course_id, + scope=scope, + reason=reason, + ) + mute.full_clean() + mute.save() + + # Create audit log + cls._create_audit_log( + "mute", + muted_user_id, + course_id, + muted_user, + muted_by_user, + reason, + scope=scope, + ) + + return mute.to_dict() + + @classmethod + @_handle_mute_errors + def unmute_user( + cls, + muted_user_id: str, + unmuted_by_id: str, + course_id: str, + scope: str = "personal", + muter_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') + muter_id: Original muter ID (for personal unmutes) + + Returns: + Dictionary containing unmute result + """ + muted_user = User.objects.get(pk=int(muted_user_id)) + unmuted_by_user = User.objects.get(pk=int(unmuted_by_id)) + + requester_is_privileged = kwargs.get( + "requester_is_privileged", cls.user_has_privileges(unmuted_by_user) + ) + + # Find active mute + mute_query = DiscussionMuteRecord.objects.filter( + muted_user=muted_user, course_id=course_id, scope=scope, is_active=True + ) + # Optimize: Use ID directly instead of fetching user object + if scope == DiscussionMuteRecord.Scope.PERSONAL and muter_id: + mute_query = mute_query.filter(muted_by__pk=int(muter_id)) + + mute = mute_query.first() + if not mute: + raise ValueError("No active mute found") + + # Permission checks + if scope == DiscussionMuteRecord.Scope.COURSE and not requester_is_privileged: + raise ValidationError("Only privileged users can unmute course-wide mutes") + + if ( + scope == DiscussionMuteRecord.Scope.PERSONAL + and mute.muted_by.pk != unmuted_by_user.pk + ): + raise ValidationError("Only the original muter can unmute a personal mute") + + # Perform unmute + mute.is_active = False + mute.unmuted_by = unmuted_by_user + mute.unmuted_at = timezone.now() + mute.save() + + # Create audit log + cls._create_audit_log( + "unmute", + muted_user_id, + course_id, + muted_user, + unmuted_by_user, + scope=scope, + ) + + 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, + } + + @classmethod + def mute_and_report_user( + cls, + muted_user_id: str, + muter_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 + muter_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 + """ + # Use existing mute_user method + mute_result = cls.mute_user( + muted_user_id=muted_user_id, + muter_id=muter_id, + course_id=course_id, + scope=scope, + reason=reason, + **kwargs, + ) + + try: + muted_user = User.objects.get(id=muted_user_id) + muter = User.objects.get(id=muter_id) + cls._create_audit_log( + "mute_and_report", + muted_user_id, + course_id, + muted_user, + muter, + reason, + reported=True, + mute_id=str(mute_result.get("id")), + ) + except Exception: # pylint: disable=broad-exception-caught + # Don't fail the operation due to audit log issues + pass + + # Add reporting flags + mute_result["reported"] = True + mute_result["action"] = "mute_and_report" + return mute_result + + @classmethod + @_handle_mute_errors + 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: + 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 + """ + user = User.objects.get(pk=int(muted_user_id)) + + # Optimize: Use single query to get all active mutes for this user in this course + mutes_query = DiscussionMuteRecord.objects.filter( + muted_user=user, + course_id=course_id, + is_active=True, + ) + + # Filter personal mutes if requesting_user_id is provided + if requesting_user_id: + mutes_query = mutes_query.filter( + Q(scope=DiscussionMuteRecord.Scope.COURSE) + | Q( + scope=DiscussionMuteRecord.Scope.PERSONAL, + muted_by__pk=int(requesting_user_id), + ) + ) + else: + # If no requesting_user_id, only return course-wide mutes + mutes_query = mutes_query.filter(scope=DiscussionMuteRecord.Scope.COURSE) + + # Execute single query and separate by scope + all_mutes = list(mutes_query) + personal_mutes = [ + m for m in all_mutes if m.scope == DiscussionMuteRecord.Scope.PERSONAL + ] + course_mutes = [ + m for m in all_mutes if m.scope == DiscussionMuteRecord.Scope.COURSE + ] + + return { + "user_id": muted_user_id, + "course_id": course_id, + "is_muted": len(all_mutes) > 0, + "personal_mute": len(personal_mutes) > 0, + "course_mute": len(course_mutes) > 0, + "mute_details": [mute.to_dict() for mute in all_mutes], + } + + @classmethod + @_handle_mute_errors + def get_all_muted_users_for_course( + cls, + course_id: str, + requester_id: Optional[str] = None, + scope: str = "all", + requester_is_privileged: bool = False, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Get all muted users in a course with role-based filtering. + + Args: + course_id: Course identifier + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + requester_is_privileged: Whether requester has course-level privileges + + Returns: + Dictionary containing list of muted users based on requester permissions + + Authorization: + - Learners: Can only see their own personal mutes + - Privileged users: Can see course-wide mutes and all personal mutes + """ + # Verify requester privileges if not explicitly provided + if requester_id and not requester_is_privileged: + try: + requester = User.objects.get(pk=int(requester_id)) + requester_is_privileged = cls.user_has_privileges(requester) + except User.DoesNotExist: + pass # Treat as non-privileged + + query = DiscussionMuteRecord.objects.filter(course_id=course_id, is_active=True) + + # Apply scope-based filtering + if requester_is_privileged: + if scope == "personal": + query = query.filter(scope=DiscussionMuteRecord.Scope.PERSONAL) + elif scope == "course": + query = query.filter(scope=DiscussionMuteRecord.Scope.COURSE) + else: + # Learners can only see their own personal mutes + if requester_id: + query = query.filter( + scope=DiscussionMuteRecord.Scope.PERSONAL, + muted_by__pk=int(requester_id), + ) + else: + query = query.none() + + muted_users = [ + mute.to_dict() for mute in query.select_related("muted_user", "muted_by") + ] + + return { + "course_id": course_id, + "scope": scope, + "muted_users": muted_users, + "total_count": len(muted_users), + } + + @classmethod + @_handle_mute_errors + 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.""" + queryset = DiscussionMuteRecord.objects.filter( + course_id=course_id, + muted_by=moderator_id, + ) + # When scope is "all", return mutes regardless of scope. + if scope != "all": + queryset = queryset.filter(scope=scope) + + if active_only: + queryset = queryset.filter(is_active=True) + + return [mute.to_dict() for mute in queryset] + + @staticmethod + def get_deleted_threads_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """Get deleted threads for a course.""" + query = CommentThread.objects.filter( + course_id=course_id, is_deleted=True, author__username=author_id + ).order_by("-deleted_at") + + total_count = query.count() + paginator = Paginator(query, per_page) + page_obj = paginator.page(page) + threads = [thread.to_dict() for thread in page_obj.object_list] + + return { + "threads": threads, + "total_count": total_count, + "page": page, + "per_page": per_page, + } + + @staticmethod + def get_deleted_comments_for_course( + course_id: str, + page: int = 1, + per_page: int = 20, + author_id: Optional[str] = None, + ) -> dict[str, Any]: + """Get deleted comments for a course.""" + query = Comment.objects.filter( + course_id=course_id, is_deleted=True, author__username=author_id + ).order_by("-deleted_at") + + # Get total count + total_count = query.count() + + # Get paginated results + paginator = Paginator(query, per_page) + try: + page_obj = paginator.page(page) + comments = [comment.to_dict() for comment in page_obj.object_list] + except Exception: # pylint: disable=broad-exception-caught + comments = [] + + return { + "comments": comments, + "total_count": total_count, + "page": page, + "per_page": per_page, + } diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index 3924a0d6..637cd629 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -1,5 +1,7 @@ """MySQL models for forum v2.""" +# mypy: ignore-errors + from __future__ import annotations from datetime import datetime @@ -8,10 +10,13 @@ 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 from django.utils.translation import gettext_lazy as _ +from model_utils.models import TimeStampedModel +from opaque_keys.edx.django.models import CourseKeyField from forum.utils import validate_upvote_or_downvote @@ -63,6 +68,9 @@ class CourseStat(models.Model): threads: models.IntegerField[int, int] = models.IntegerField(default=0) responses: models.IntegerField[int, int] = models.IntegerField(default=0) replies: models.IntegerField[int, int] = models.IntegerField(default=0) + deleted_threads: models.IntegerField[int, int] = models.IntegerField(default=0) + deleted_responses: models.IntegerField[int, int] = models.IntegerField(default=0) + deleted_replies: models.IntegerField[int, int] = models.IntegerField(default=0) last_activity_at: models.DateTimeField[Optional[datetime], datetime] = ( models.DateTimeField(default=None, null=True, blank=True) ) @@ -79,6 +87,12 @@ def to_dict(self) -> dict[str, Any]: "threads": self.threads, "responses": self.responses, "replies": self.replies, + "deleted_threads": self.deleted_threads, + "deleted_responses": self.deleted_responses, + "deleted_replies": self.deleted_replies, + "deleted_count": self.deleted_threads + + self.deleted_responses + + self.deleted_replies, "course_id": self.course_id, "last_activity_at": self.last_activity_at, } @@ -125,6 +139,29 @@ class Content(models.Model): updated_at: models.DateTimeField[datetime, datetime] = models.DateTimeField( auto_now=True ) + is_spam: models.BooleanField[bool, bool] = models.BooleanField( + default=False, + help_text="Whether this content has been identified as spam by AI moderation", + ) + is_deleted: models.BooleanField[bool, bool] = models.BooleanField( + default=False, + help_text="Whether this content has been soft deleted", + ) + deleted_at: models.DateTimeField[Optional[datetime], datetime] = ( + models.DateTimeField( + null=True, + blank=True, + help_text="When this content was soft deleted", + ) + ) + deleted_by: models.ForeignKey[User, User] = models.ForeignKey( + User, + related_name="deleted_%(class)s", + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text="User who soft deleted this content", + ) uservote = GenericRelation( "UserVote", object_id_field="content_object_id", @@ -263,8 +300,8 @@ class CommentThread(Content): @property def comment_count(self) -> int: - """Return the number of comments in the thread.""" - return Comment.objects.filter(comment_thread=self).count() + """Return the number of comments in the thread (excluding deleted).""" + return Comment.objects.filter(comment_thread=self, is_deleted=False).count() @classmethod def get(cls, thread_id: str) -> CommentThread: @@ -318,6 +355,10 @@ def to_dict(self) -> dict[str, Any]: "last_activity_at": self.last_activity_at, "edit_history": edit_history, "group_id": self.group_id, + "is_spam": self.is_spam, + "is_deleted": self.is_deleted, + "deleted_at": self.deleted_at, + "deleted_by": str(self.deleted_by.pk) if self.deleted_by else None, } def doc_to_hash(self) -> dict[str, Any]: @@ -353,6 +394,9 @@ class Meta: models.Index( fields=["author", "course_id", "anonymous", "anonymous_to_peers"] ), + models.Index(fields=["is_spam"]), + models.Index(fields=["course_id", "is_spam"]), + models.Index(fields=["author", "course_id", "is_spam"]), ] @@ -500,6 +544,10 @@ def to_dict(self) -> dict[str, Any]: "updated_at": self.updated_at, "created_at": self.created_at, "endorsement": endorsement if self.endorsement else None, + "is_spam": self.is_spam, + "is_deleted": self.is_deleted, + "deleted_at": self.deleted_at, + "deleted_by": str(self.deleted_by.pk) if self.deleted_by else None, } if edit_history: data["edit_history"] = edit_history @@ -538,6 +586,9 @@ class Meta: models.Index( fields=["author", "course_id", "anonymous", "anonymous_to_peers"] ), + models.Index(fields=["is_spam"]), + models.Index(fields=["course_id", "is_spam"]), + models.Index(fields=["author", "course_id", "is_spam"]), ] @@ -774,3 +825,644 @@ class MongoContent(models.Model): class Meta: app_label = "forum" + + +class ModerationAuditLog(models.Model): + """ + Unified audit log for all discussion moderation actions. + + Tracks both human moderator actions (bans, content removal) and + AI moderation decisions (spam detection, auto-flagging). + """ + + # Moderation source - who initiated the action + SOURCE_HUMAN = "human" + SOURCE_AI = "ai" + SOURCE_SYSTEM = "system" + SOURCE_CHOICES = [ + (SOURCE_HUMAN, "Human Moderator"), + (SOURCE_AI, "AI Classifier"), + (SOURCE_SYSTEM, "System/Automated"), + ] + + # Unified action types for both human and AI moderation + # Human moderator actions on users + ACTION_BAN = "ban_user" + ACTION_BAN_REACTIVATE = "ban_reactivate" + ACTION_UNBAN = "unban_user" + ACTION_BAN_EXCEPTION = "ban_exception" + ACTION_BULK_DELETE = "bulk_delete" + # AI/Human actions on content + ACTION_FLAGGED = "flagged" + ACTION_SOFT_DELETED = "soft_deleted" + ACTION_APPROVED = "approved" + ACTION_NO_ACTION = "no_action" + + ACTION_CHOICES = [ + # Human moderator actions on users + (ACTION_BAN, "Ban User"), + (ACTION_BAN_REACTIVATE, "Ban Reactivated"), + (ACTION_UNBAN, "Unban User"), + (ACTION_BAN_EXCEPTION, "Ban Exception Created"), + (ACTION_BULK_DELETE, "Bulk Delete"), + # AI/Human actions on content + (ACTION_FLAGGED, "Content Flagged"), + (ACTION_SOFT_DELETED, "Content Soft Deleted"), + (ACTION_APPROVED, "Content Approved"), + (ACTION_NO_ACTION, "No Action Taken"), + ("flagged", "Content Flagged"), + ("soft_deleted", "Content Soft Deleted"), + ("no_action", "No Action Taken"), + ("mute", "Mute"), + ("unmute", "Unmute"), + ("mute_and_report", "Mute and Report"), + ] + + # AI classification types (only for AI moderation) + CLASSIFICATION_SPAM = "spam" + CLASSIFICATION_SPAM_OR_SCAM = "spam_or_scam" + CLASSIFICATION_CHOICES = [ + (CLASSIFICATION_SPAM, "Spam"), + (CLASSIFICATION_SPAM_OR_SCAM, "Spam or Scam"), + ] + + # === Core Fields === + action_type: models.CharField[str, str] = models.CharField( + max_length=50, + choices=ACTION_CHOICES, + default=ACTION_NO_ACTION, + db_index=True, + help_text="Type of moderation action taken", + ) + source: models.CharField[str, str] = models.CharField( + max_length=20, + choices=SOURCE_CHOICES, + default=SOURCE_AI, + db_index=True, + help_text="Who initiated the moderation action", + ) + timestamp: models.DateTimeField[datetime, datetime] = models.DateTimeField( + default=timezone.now, + db_index=True, + help_text="When the moderation action was taken", + ) + + # === Target Fields === + # For user-targeted actions (bans/unbans) + target_user: models.ForeignKey[Optional[User], Optional[User]] = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="audit_log_actions_received", + db_index=True, + help_text="Target user for user moderation actions (ban/unban)", + ) + # For content-targeted actions (AI moderation) + body: models.TextField[Optional[str], str] = models.TextField( + null=True, + blank=True, + help_text="Content body that was moderated (for content moderation)", + ) + original_author: models.ForeignKey[Optional[User], Optional[User]] = ( + models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="moderated_content", + help_text="Original author of the moderated content", + ) + ) + + # === Actor Fields === + moderator: models.ForeignKey[Optional[User], Optional[User]] = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="audit_log_actions_performed", + db_index=True, + help_text="Human moderator who performed or overrode the action", + ) + + # === Context Fields === + course_id: models.CharField[Optional[str], str] = models.CharField( + max_length=255, + null=True, + blank=True, + db_index=True, + help_text="Course ID for course-level moderation actions", + ) + scope: models.CharField[Optional[str], str] = models.CharField( + max_length=20, + null=True, + blank=True, + help_text="Scope of moderation (course/organization)", + ) + reason: models.TextField[Optional[str], str] = models.TextField( + null=True, + blank=True, + help_text="Reason provided for the moderation action", + ) + + # === AI-specific Fields (only populated for source='ai') === + classifier_output: models.JSONField[Optional[dict[str, Any]], dict[str, Any]] = ( + models.JSONField( + null=True, + blank=True, + help_text="Full output from the AI classifier", + ) + ) + classification: models.CharField[Optional[str], str] = models.CharField( + max_length=20, + choices=CLASSIFICATION_CHOICES, + null=True, + blank=True, + help_text="AI classification result", + ) + actions_taken: models.JSONField[Optional[list[str]], list[str]] = models.JSONField( + null=True, + blank=True, + help_text="List of actions taken (for AI: ['flagged', 'soft_deleted'])", + ) + confidence_score: models.FloatField[Optional[float], float] = models.FloatField( + null=True, + blank=True, + help_text="AI confidence score if available", + ) + reasoning: models.TextField[Optional[str], str] = models.TextField( + null=True, + blank=True, + help_text="AI reasoning for the decision", + ) + + # === Override Fields (when human overrides AI) === + moderator_override: models.BooleanField[bool, bool] = models.BooleanField( + default=False, + help_text="Whether a human moderator overrode the AI decision", + ) + override_reason: models.TextField[Optional[str], str] = models.TextField( + null=True, + blank=True, + help_text="Reason for moderator override", + ) + + # === Flexible Metadata === + metadata: models.JSONField[Optional[dict[str, Any]], dict[str, Any]] = ( + models.JSONField( + null=True, + blank=True, + help_text="Additional context (task IDs, counts, etc.)", + ) + ) + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the model.""" + data: dict[str, Any] = { + "_id": str(self.pk), + "action_type": self.action_type, + "source": self.source, + "timestamp": self.timestamp.isoformat(), + "moderator_id": str(self.moderator.pk) if self.moderator else None, + "moderator_username": self.moderator.username if self.moderator else None, + "course_id": self.course_id, + "scope": self.scope, + "reason": self.reason, + "metadata": self.metadata, + } + + # Add user moderation fields + if self.target_user: + data["target_user_id"] = str(self.target_user.pk) + data["target_user_username"] = self.target_user.username + + # Add content moderation fields + if self.body: + data["body"] = self.body + if self.original_author: + data["original_author_id"] = str(self.original_author.pk) + data["original_author_username"] = self.original_author.username + + # Add AI-specific fields + if self.source == self.SOURCE_AI: + data.update( + { + "classifier_output": self.classifier_output, + "classification": self.classification, + "actions_taken": self.actions_taken, + "confidence_score": self.confidence_score, + "reasoning": self.reasoning, + "moderator_override": self.moderator_override, + "override_reason": self.override_reason, + } + ) + + return data + + class Meta: + app_label = "forum" + verbose_name = "Moderation Audit Log" + verbose_name_plural = "Moderation Audit Logs" + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["timestamp"]), + models.Index(fields=["action_type", "-timestamp"]), + models.Index(fields=["source", "-timestamp"]), + models.Index(fields=["target_user", "-timestamp"]), + models.Index(fields=["original_author", "-timestamp"]), + models.Index(fields=["moderator", "-timestamp"]), + models.Index(fields=["course_id", "-timestamp"]), + models.Index(fields=["classification"]), + models.Index(fields=["original_author"]), + models.Index(fields=["moderator"]), + models.Index(fields=["course_id"]), + ] + + +# ============================================================================== +# DISCUSSION BAN MODELS +# ============================================================================== +# NOTE: These models were migrated from lms.djangoapps.discussion.models +# +# MIGRATION HISTORY: +# - Originally in lms.djangoapps.discussion.models +# - Tables created by forum/migrations/0006_add_discussion_ban_models.py +# - Old discussion app migration will be replaced with a deletion migration +# ============================================================================== + + +class DiscussionBan(TimeStampedModel): + """ + Tracks users banned from course or organization discussions. + + Uses edX standard patterns: + - TimeStampedModel for created/modified timestamps + - CourseKeyField for course_id + - Soft delete pattern with is_active flag + """ + + SCOPE_COURSE = "course" + SCOPE_ORGANIZATION = "organization" + SCOPE_CHOICES = [ + (SCOPE_COURSE, _("Course")), + (SCOPE_ORGANIZATION, _("Organization")), + ] + + id: int + + # Core Fields + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="discussion_bans", + db_index=True, + ) + course_id = CourseKeyField( + max_length=255, + db_index=True, + null=True, + blank=True, + help_text="Specific course for course-level bans, NULL for org-level bans", + ) + org_key = models.CharField( + max_length=255, + db_index=True, + null=True, + blank=True, + help_text="Organization name for org-level bans (e.g., 'HarvardX'), NULL for course-level", + ) + scope = models.CharField( + max_length=20, + choices=SCOPE_CHOICES, + default=SCOPE_COURSE, + db_index=True, + ) + is_active = models.BooleanField(default=True, db_index=True) + + # Metadata + banned_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="bans_issued", + ) + reason = models.TextField() + banned_at = models.DateTimeField(auto_now_add=True) + unbanned_at = models.DateTimeField(null=True, blank=True) + unbanned_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="bans_reversed", + ) + + class Meta: + app_label = "forum" + db_table = "discussion_user_ban" + indexes = [ + models.Index(fields=["user", "is_active"], name="idx_user_active"), + models.Index(fields=["course_id", "is_active"], name="idx_course_active"), + models.Index(fields=["org_key", "is_active"], name="idx_org_active"), + models.Index(fields=["scope", "is_active"], name="idx_scope_active"), + ] + constraints = [ + # Prevent duplicate course-level bans + models.UniqueConstraint( + fields=["user", "course_id"], + condition=models.Q(is_active=True, scope="course"), + name="unique_active_course_ban", + ), + # Prevent duplicate org-level bans + models.UniqueConstraint( + fields=["user", "org_key"], + condition=models.Q(is_active=True, scope="organization"), + name="unique_active_org_ban", + ), + ] + verbose_name = _("Discussion Ban") + verbose_name_plural = _("Discussion Bans") + + def __str__(self): + if self.scope == self.SCOPE_COURSE: + return f"Ban: {self.user.username} in {self.course_id} (course-level)" + else: + return f"Ban: {self.user.username} in {self.org_key} (org-level)" + + def clean(self): + """Validate scope-based field requirements.""" + super().clean() + if self.scope == self.SCOPE_COURSE: + if not self.course_id: + raise ValidationError(_("Course-level bans require course_id")) + elif self.scope == self.SCOPE_ORGANIZATION: + if not self.org_key: + raise ValidationError(_("Organization-level bans require organization")) + if self.course_id: + raise ValidationError( + _("Organization-level bans should not have course_id set") + ) + + @classmethod + def is_user_banned(cls, user, course_id, check_org=True): + """ + Check if user is banned from discussions. + + Priority: + 1. Active course-level ban (most specific - overrides everything) + 2. Organization-level ban with exceptions (broader scope) + + Note: Inactive course-level bans do NOT prevent org-level bans from applying. + Unbanning at course level only removes that specific course ban, not org bans. + + Args: + user: User object + course_id: CourseKey or string + check_org: If True, also check organization-level bans + + Returns: + bool: True if user has active ban + """ + # pylint: disable=import-outside-toplevel + from opaque_keys.edx.keys import CourseKey + + # Normalize course_id to CourseKey + if isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + + # Check for ACTIVE course-level ban first (highest priority) + # Only active bans matter - inactive bans don't prevent org-level bans + if cls.objects.filter( + user=user, course_id=course_id, scope=cls.SCOPE_COURSE, is_active=True + ).exists(): + return True + + # Check organization-level ban (lower priority) + if check_org: + # Try to get organization from CourseOverview, fallback to CourseKey + try: + # pylint: disable=import-outside-toplevel + from openedx.core.djangoapps.content.course_overviews.models import ( + CourseOverview, + ) + + course = CourseOverview.objects.get(id=course_id) + org_name = course.org + # pylint: disable=broad-exception-caught + except ( + ImportError, + AttributeError, + Exception, + ): + # Fallback: extract org directly from course_id + # ImportError: CourseOverview not available (test environment) + # AttributeError: Missing settings.FEATURES + # Exception: CourseOverview.DoesNotExist or other DB issues + org_name = course_id.org + + # Check if org-level ban exists + org_ban = cls.objects.filter( + user=user, + org_key=org_name, + scope=cls.SCOPE_ORGANIZATION, + is_active=True, + ).first() + + if org_ban: + # Check if there's an exception for this specific course + if DiscussionBanException.objects.filter( + ban=org_ban, course_id=course_id + ).exists(): + # Exception exists - user is allowed in this course + return False + # Org ban applies, no exception + return True + + return False + + +class DiscussionBanException(TimeStampedModel): + """ + Tracks course-level exceptions to organization-level bans. + + Allows moderators to unban a user from specific courses while + maintaining an organization-wide ban for all other courses. + + Uses edX standard patterns: + - TimeStampedModel for created/modified timestamps + + Example: + - User banned from all HarvardX courses (org-level ban) + - Exception created for HarvardX+CS50+2024 + - User can participate in CS50 but remains banned in all other HarvardX courses + """ + + id: int + + # Core Fields + ban = models.ForeignKey( + "DiscussionBan", + on_delete=models.CASCADE, + related_name="exceptions", + help_text="The organization-level ban this exception applies to", + ) + course_id = CourseKeyField( + max_length=255, + db_index=True, + help_text="Specific course where user is unbanned despite org-level ban", + ) + + # Metadata + unbanned_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="ban_exceptions_created", + ) + reason = models.TextField(null=True, blank=True) + + class Meta: + app_label = "forum" + db_table = "discussion_ban_exception" + constraints = [ + models.UniqueConstraint( + fields=["ban", "course_id"], name="unique_ban_exception" + ), + ] + indexes = [ + models.Index(fields=["ban", "course_id"], name="idx_ban_course"), + models.Index(fields=["course_id"], name="idx_exception_course"), + ] + verbose_name = _("Discussion Ban Exception") + verbose_name_plural = _("Discussion Ban Exceptions") + + def __str__(self): + return f"Exception: {self.ban.user.username} allowed in {self.course_id}" + + def clean(self): + """Validate that exception only applies to organization-level bans.""" + super().clean() + if self.ban.scope != "organization": + raise ValidationError( + _("Exceptions can only be created for organization-level bans") + ) + + +class DiscussionMuteRecord(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, + "muter_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 for mute records.""" + + # Mutes cannot be self-applied + if self.muted_by == self.muted_user: + raise ValidationError("Users cannot mute themselves.") + + def __str__(self) -> str: + return f"{self.muted_by} muted {self.muted_user} in {self.course_id} ({self.scope})" diff --git a/forum/migration_helpers.py b/forum/migration_helpers.py index 86a85db3..149ea030 100644 --- a/forum/migration_helpers.py +++ b/forum/migration_helpers.py @@ -2,6 +2,7 @@ from typing import Any import logging +from datetime import datetime from django.contrib.auth.models import User # pylint: disable=E5142 from django.core.management.base import OutputWrapper @@ -37,6 +38,23 @@ def get_user_or_none(user_id: Any) -> User | None: return None +def parse_mongo_datetime(value: Any) -> datetime | None: + """ + Parse a MongoDB datetime value to a timezone-aware datetime. + + MongoDB may return datetime as string or datetime object. + This function handles both cases. + """ + if not value: + return None + + if isinstance(value, str): + # Parse ISO format string, handle 'Z' suffix for UTC + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + + return make_aware(value) + + def get_all_course_ids(db: Database[dict[str, Any]]) -> list[str]: """Get all course IDs from MongoDB.""" return db.contents.distinct("course_id") @@ -56,9 +74,7 @@ def migrate_users(db: Database[dict[str, Any]], course_id: str) -> None: ) for stat in user_data.get("course_stats", []): - last_activity_at = stat.get("last_activity_at") - if last_activity_at: - last_activity_at = make_aware(last_activity_at) + last_activity_at = parse_mongo_datetime(stat.get("last_activity_at")) if stat["course_id"] == course_id: CourseStat.objects.update_or_create( user=user, @@ -69,6 +85,9 @@ def migrate_users(db: Database[dict[str, Any]], course_id: str) -> None: "threads": stat.get("threads", 0), "responses": stat.get("responses", 0), "replies": stat.get("replies", 0), + "deleted_threads": stat.get("deleted_threads", 0), + "deleted_responses": stat.get("deleted_responses", 0), + "deleted_replies": stat.get("deleted_replies", 0), "last_activity_at": last_activity_at, }, ) @@ -110,6 +129,11 @@ def create_or_update_thread(thread_data: dict[str, Any]) -> None: mongo_id=mongo_thread_id, ) if not mongo_content.content_object_id: + # Get deleted_by user if deleted_by field exists in MongoDB + deleted_by = None + if thread_data.get("deleted_by"): + deleted_by = get_user_or_none(thread_data["deleted_by"]) + thread = CommentThread.objects.create( author=author, author_username=author_username, @@ -123,23 +147,57 @@ def create_or_update_thread(thread_data: dict[str, Any]) -> None: anonymous_to_peers=thread_data.get("anonymous_to_peers", False), closed=thread_data.get("closed", False), pinned=thread_data.get("pinned"), - created_at=make_aware(thread_data["created_at"]), - updated_at=make_aware(thread_data["updated_at"]), - last_activity_at=make_aware(thread_data["last_activity_at"]), + created_at=parse_mongo_datetime(thread_data["created_at"]), + updated_at=parse_mongo_datetime(thread_data["updated_at"]), + last_activity_at=parse_mongo_datetime(thread_data["last_activity_at"]), commentable_id=thread_data.get("commentable_id"), + # Moderation fields + is_spam=thread_data.get("is_spam", False), + is_deleted=thread_data.get("is_deleted", False), + deleted_at=parse_mongo_datetime(thread_data.get("deleted_at")), + deleted_by=deleted_by, + visible=thread_data.get("visible", True), ) mongo_content.content_object_id = thread.pk mongo_content.content_type = thread.content_type mongo_content.save() else: + # Update existing thread with latest data from MongoDB thread = CommentThread.objects.get(pk=mongo_content.content_object_id) + # Get deleted_by user if needed + deleted_by = None + if thread_data.get("deleted_by"): + deleted_by = get_user_or_none(thread_data["deleted_by"]) + + # Update all fields that might have changed + thread.title = get_trunc_title(thread_data.get("title", "")) + thread.body = thread_data["body"] + thread.thread_type = thread_data.get("thread_type", "discussion") + thread.context = thread_data.get("context", "course") + thread.anonymous = thread_data.get("anonymous", False) + thread.anonymous_to_peers = thread_data.get("anonymous_to_peers", False) + thread.closed = thread_data.get("closed", False) + thread.pinned = thread_data.get("pinned") + thread.updated_at = parse_mongo_datetime(thread_data["updated_at"]) # type: ignore[assignment] + thread.last_activity_at = parse_mongo_datetime(thread_data["last_activity_at"]) + thread.commentable_id = thread_data.get("commentable_id") # type: ignore[assignment] + # Update moderation fields + thread.is_spam = thread_data.get("is_spam", False) + thread.is_deleted = thread_data.get("is_deleted", False) + thread.deleted_at = parse_mongo_datetime(thread_data.get("deleted_at")) + thread.deleted_by = deleted_by # type: ignore[assignment] + thread.visible = thread_data.get("visible", True) + thread.save() + create_or_update_edit_history(thread_data) create_or_update_abuse_flaggers(thread_data) create_votes(thread, thread_data.get("votes", {})) -def create_or_update_comment(comment_data: dict[str, Any]) -> None: +def create_or_update_comment( # pylint: disable=too-many-statements + comment_data: dict[str, Any], +) -> None: """Create or update a comment.""" author = get_user_or_none(comment_data["author_id"]) if not author: @@ -197,6 +255,11 @@ def create_or_update_comment(comment_data: dict[str, Any]) -> None: mongo_id=str(comment_data["_id"]) ) if not mongo_comment.content_object_id: + # Get deleted_by user if deleted_by field exists in MongoDB + deleted_by = None + if comment_data.get("deleted_by"): + deleted_by = get_user_or_none(comment_data["deleted_by"]) + comment = Comment.objects.create( author=author, author_username=author_username, @@ -209,9 +272,15 @@ def create_or_update_comment(comment_data: dict[str, Any]) -> None: anonymous_to_peers=comment_data.get("anonymous_to_peers", False), endorsed=comment_data.get("endorsed", False), child_count=comment_data.get("child_count", 0), - created_at=make_aware(comment_data["created_at"]), - updated_at=make_aware(comment_data["updated_at"]), + created_at=parse_mongo_datetime(comment_data["created_at"]), + updated_at=parse_mongo_datetime(comment_data["updated_at"]), depth=1 if parent else 0, + # Moderation fields + is_spam=comment_data.get("is_spam", False), + is_deleted=comment_data.get("is_deleted", False), + deleted_at=parse_mongo_datetime(comment_data.get("deleted_at")), + deleted_by=deleted_by, + visible=comment_data.get("visible", True), ) mongo_comment.content_object_id = comment.pk mongo_comment.content_type = comment.content_type @@ -220,8 +289,29 @@ def create_or_update_comment(comment_data: dict[str, Any]) -> None: comment.sort_key = sort_key comment.save() else: + # Update existing comment with latest data from MongoDB comment = Comment.objects.get(pk=mongo_comment.content_object_id) + # Get deleted_by user if needed + deleted_by = None + if comment_data.get("deleted_by"): + deleted_by = get_user_or_none(comment_data["deleted_by"]) + + # Update all fields that might have changed + comment.body = comment_data["body"] + comment.anonymous = comment_data.get("anonymous", False) + comment.anonymous_to_peers = comment_data.get("anonymous_to_peers", False) + comment.endorsed = comment_data.get("endorsed", False) + comment.child_count = comment_data.get("child_count", 0) + comment.updated_at = parse_mongo_datetime(comment_data["updated_at"]) # type: ignore[assignment] + # Update moderation fields + comment.is_spam = comment_data.get("is_spam", False) + comment.is_deleted = comment_data.get("is_deleted", False) + comment.deleted_at = parse_mongo_datetime(comment_data.get("deleted_at")) + comment.deleted_by = deleted_by # type: ignore[assignment] + comment.visible = comment_data.get("visible", True) + comment.save() + create_or_update_edit_history(comment_data) create_or_update_abuse_flaggers(comment_data) create_votes(comment, comment_data.get("votes", {})) @@ -268,7 +358,7 @@ def create_or_update_edit_history(content: dict[str, Any]) -> None: EditHistory.objects.get_or_create( content_object_id=content_object.pk, content_type=content_object.content_type, - created_at=edit["created_at"], + created_at=parse_mongo_datetime(edit["created_at"]), editor=editor, defaults={ "original_body": edit["original_body"], @@ -352,8 +442,10 @@ def migrate_subscriptions(db: Database[dict[str, Any]], content_id: str) -> None source_content_type=content.content_type, source_object_id=content.pk, defaults={ - "created_at": sub.get("created_at", timezone.now()), - "updated_at": sub.get("updated_at", timezone.now()), + "created_at": parse_mongo_datetime(sub.get("created_at")) + or timezone.now(), + "updated_at": parse_mongo_datetime(sub.get("updated_at")) + or timezone.now(), }, ) @@ -390,10 +482,10 @@ def migrate_read_states(db: Database[dict[str, Any]], course_id: str) -> None: LastReadTime.objects.create( read_state=rs, comment_thread=thread, - timestamp=make_aware(timestamp), + timestamp=parse_mongo_datetime(timestamp), ) else: - existing_read_time.timestamp = make_aware(timestamp) + existing_read_time.timestamp = parse_mongo_datetime(timestamp) # type: ignore[assignment] existing_read_time.save() diff --git a/forum/migrations/0005_moderationauditlog_comment_is_spam_and_more.py b/forum/migrations/0005_moderationauditlog_comment_is_spam_and_more.py new file mode 100644 index 00000000..3627a9fa --- /dev/null +++ b/forum/migrations/0005_moderationauditlog_comment_is_spam_and_more.py @@ -0,0 +1,191 @@ +# Generated by Django 5.2.7 on 2025-11-05 08:11 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forum", "0004_add_author_username_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ModerationAuditLog", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "timestamp", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="When the moderation decision was made", + ), + ), + ( + "body", + models.TextField(help_text="The content body that was moderated"), + ), + ( + "classifier_output", + models.JSONField(help_text="Full output from the AI classifier"), + ), + ( + "reasoning", + models.TextField(help_text="AI reasoning for the decision"), + ), + ( + "classification", + models.CharField( + choices=[("spam", "Spam"), ("spam_or_scam", "Spam or Scam")], + help_text="AI classification result", + max_length=20, + ), + ), + ( + "actions_taken", + models.JSONField( + default=list, + help_text="List of actions taken based on moderation (e.g., ['flagged', 'soft_deleted'])", + ), + ), + ( + "confidence_score", + models.FloatField( + blank=True, + help_text="AI confidence score if available", + null=True, + ), + ), + ( + "moderator_override", + models.BooleanField( + default=False, + help_text="Whether a human moderator overrode the AI decision", + ), + ), + ( + "override_reason", + models.TextField( + blank=True, help_text="Reason for moderator override", null=True + ), + ), + ], + options={ + "verbose_name": "Moderation Audit Log", + "verbose_name_plural": "Moderation Audit Logs", + "ordering": ["-timestamp"], + }, + ), + migrations.AddField( + model_name="comment", + name="is_spam", + field=models.BooleanField( + default=False, + help_text="Whether this content has been identified as spam by AI moderation", + ), + ), + migrations.AddField( + model_name="commentthread", + name="is_spam", + field=models.BooleanField( + default=False, + help_text="Whether this content has been identified as spam by AI moderation", + ), + ), + migrations.AddIndex( + model_name="comment", + index=models.Index( + fields=["is_spam"], name="forum_comme_is_spam_46c762_idx" + ), + ), + migrations.AddIndex( + model_name="comment", + index=models.Index( + fields=["course_id", "is_spam"], name="forum_comme_course__4a265f_idx" + ), + ), + migrations.AddIndex( + model_name="comment", + index=models.Index( + fields=["author", "course_id", "is_spam"], + name="forum_comme_author__dde6dd_idx", + ), + ), + migrations.AddIndex( + model_name="commentthread", + index=models.Index( + fields=["is_spam"], name="forum_comme_is_spam_0e7304_idx" + ), + ), + migrations.AddIndex( + model_name="commentthread", + index=models.Index( + fields=["course_id", "is_spam"], name="forum_comme_course__2c84e0_idx" + ), + ), + migrations.AddIndex( + model_name="commentthread", + index=models.Index( + fields=["author", "course_id", "is_spam"], + name="forum_comme_author__96f3e5_idx", + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="moderator", + field=models.ForeignKey( + blank=True, + help_text="Human moderator who made override", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderation_actions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="original_author", + field=models.ForeignKey( + help_text="Original author of the moderated content", + on_delete=django.db.models.deletion.CASCADE, + related_name="moderated_content", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["timestamp"], name="forum_moder_timesta_0d4616_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["classification"], name="forum_moder_classif_f477d2_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["original_author"], name="forum_moder_origina_c51089_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["moderator"], name="forum_moder_moderat_c62a1c_idx" + ), + ), + ] diff --git a/forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py b/forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py new file mode 100644 index 00000000..eb679768 --- /dev/null +++ b/forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.7 on 2025-12-11 05:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forum", "0005_moderationauditlog_comment_is_spam_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="deleted_at", + field=models.DateTimeField( + blank=True, help_text="When this content was soft deleted", null=True + ), + ), + migrations.AddField( + model_name="comment", + name="deleted_by", + field=models.ForeignKey( + blank=True, + help_text="User who soft deleted this content", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deleted_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="comment", + name="is_deleted", + field=models.BooleanField( + default=False, help_text="Whether this content has been soft deleted" + ), + ), + migrations.AddField( + model_name="commentthread", + name="deleted_at", + field=models.DateTimeField( + blank=True, help_text="When this content was soft deleted", null=True + ), + ), + migrations.AddField( + model_name="commentthread", + name="deleted_by", + field=models.ForeignKey( + blank=True, + help_text="User who soft deleted this content", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deleted_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="commentthread", + name="is_deleted", + field=models.BooleanField( + default=False, help_text="Whether this content has been soft deleted" + ), + ), + migrations.AddField( + model_name="coursestat", + name="deleted_replies", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="coursestat", + name="deleted_responses", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="coursestat", + name="deleted_threads", + field=models.IntegerField(default=0), + ), + ] diff --git a/forum/migrations/0007_add_discussion_ban_models.py b/forum/migrations/0007_add_discussion_ban_models.py new file mode 100644 index 00000000..183dfca0 --- /dev/null +++ b/forum/migrations/0007_add_discussion_ban_models.py @@ -0,0 +1,486 @@ +"""Migration to add discussion ban models to forum app.""" + +# mypy: ignore-errors + +from typing import Any + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import opaque_keys.edx.django.models + + +def populate_source_with_ai( + apps: Any, schema_editor: Any +) -> None: # pylint: disable=unused-argument + """ + Populate existing ModerationAuditLog records with source='ai'. + + This migration updates all existing records in production to have source='ai'. + After AlterField runs, the field will exist with default='ai', but any records + that were created before this migration might have source='human' (the old default). + This function updates those records to 'ai'. + + Note: This assumes the 'source' field already exists in the database. + If migration 0005 didn't create it, AlterField will add it with default='ai'. + """ + ModerationAuditLog = apps.get_model("forum", "ModerationAuditLog") + + try: + ModerationAuditLog.objects.exclude(source="ai").update(source="ai") + except Exception: # pylint: disable=broad-exception-caught + pass + + +def reverse_populate_source( + apps: Any, schema_editor: Any +) -> None: # pylint: disable=unused-argument + """ + Reverse migration: Set source back to 'human' for records that were updated. + + Note: This is a best-effort reversal. We can't perfectly restore the original + state since we don't know which records were originally 'human' vs 'ai'. + We set them all back to 'human' as a safe default. + """ + ModerationAuditLog = apps.get_model("forum", "ModerationAuditLog") + + ModerationAuditLog.objects.filter(source="ai").update(source="human") + + +class Migration(migrations.Migration): + """Migration to add discussion ban and moderation models.""" + + dependencies = [ + ("forum", "0006_comment_deleted_at_comment_deleted_by_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DiscussionBan", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "course_id", + opaque_keys.edx.django.models.CourseKeyField( + blank=True, + db_index=True, + help_text="Specific course for course-level bans, NULL for org-level bans", + max_length=255, + null=True, + ), + ), + ( + "org_key", + models.CharField( + blank=True, + db_index=True, + help_text="Organization name for org-level bans (e.g., 'HarvardX'), NULL for course-level", + max_length=255, + null=True, + ), + ), + ( + "scope", + models.CharField( + choices=[ + ("course", "Course"), + ("organization", "Organization"), + ], + db_index=True, + default="course", + max_length=20, + ), + ), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("reason", models.TextField()), + ("banned_at", models.DateTimeField(auto_now_add=True)), + ("unbanned_at", models.DateTimeField(blank=True, null=True)), + ( + "banned_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bans_issued", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "unbanned_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bans_reversed", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + db_index=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="discussion_bans", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Discussion Ban", + "verbose_name_plural": "Discussion Bans", + "db_table": "discussion_user_ban", + }, + ), + migrations.CreateModel( + name="DiscussionBanException", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "course_id", + opaque_keys.edx.django.models.CourseKeyField( + db_index=True, + help_text="Specific course where user is unbanned despite org-level ban", + max_length=255, + ), + ), + ("reason", models.TextField(blank=True, null=True)), + ( + "ban", + models.ForeignKey( + help_text="The organization-level ban this exception applies to", + on_delete=django.db.models.deletion.CASCADE, + related_name="exceptions", + to="forum.discussionban", + ), + ), + ( + "unbanned_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ban_exceptions_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Discussion Ban Exception", + "verbose_name_plural": "Discussion Ban Exceptions", + "db_table": "discussion_ban_exception", + }, + ), + migrations.AddConstraint( + model_name="discussionbanexception", + constraint=models.UniqueConstraint( + fields=("ban", "course_id"), name="unique_ban_exception" + ), + ), + migrations.AddIndex( + model_name="discussionbanexception", + index=models.Index(fields=["ban", "course_id"], name="idx_ban_course"), + ), + migrations.AddIndex( + model_name="discussionbanexception", + index=models.Index(fields=["course_id"], name="idx_exception_course"), + ), + migrations.AddIndex( + model_name="discussionban", + index=models.Index(fields=["user", "is_active"], name="idx_user_active"), + ), + migrations.AddIndex( + model_name="discussionban", + index=models.Index( + fields=["course_id", "is_active"], name="idx_course_active" + ), + ), + migrations.AddIndex( + model_name="discussionban", + index=models.Index(fields=["org_key", "is_active"], name="idx_org_active"), + ), + migrations.AddIndex( + model_name="discussionban", + index=models.Index(fields=["scope", "is_active"], name="idx_scope_active"), + ), + migrations.AddConstraint( + model_name="discussionban", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True), ("scope", "course")), + fields=("user", "course_id"), + name="unique_active_course_ban", + ), + ), + migrations.AddConstraint( + model_name="discussionban", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True), ("scope", "organization")), + fields=("user", "org_key"), + name="unique_active_org_ban", + ), + ), + migrations.RemoveIndex( + model_name="moderationauditlog", + name="forum_moder_origina_c51089_idx", + ), + migrations.RemoveIndex( + model_name="moderationauditlog", + name="forum_moder_moderat_c62a1c_idx", + ), + migrations.AddField( + model_name="moderationauditlog", + name="action_type", + field=models.CharField( + choices=[ + ("ban_user", "Ban User"), + ("ban_reactivate", "Ban Reactivated"), + ("unban_user", "Unban User"), + ("ban_exception", "Ban Exception Created"), + ("bulk_delete", "Bulk Delete"), + ("flagged", "Content Flagged"), + ("soft_deleted", "Content Soft Deleted"), + ("approved", "Content Approved"), + ("no_action", "No Action Taken"), + ], + db_index=True, + default="no_action", + help_text="Type of moderation action taken", + max_length=50, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="course_id", + field=models.CharField( + blank=True, + db_index=True, + help_text="Course ID for course-level moderation actions", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="metadata", + field=models.JSONField( + blank=True, + help_text="Additional context (task IDs, counts, etc.)", + null=True, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="reason", + field=models.TextField( + blank=True, + help_text="Reason provided for the moderation action", + null=True, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="scope", + field=models.CharField( + blank=True, + help_text="Scope of moderation (course/organization)", + max_length=20, + null=True, + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="target_user", + field=models.ForeignKey( + blank=True, + help_text="Target user for user moderation actions (ban/unban)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="audit_log_actions_received", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="actions_taken", + field=models.JSONField( + blank=True, + help_text="List of actions taken (for AI: ['flagged', 'soft_deleted'])", + null=True, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="body", + field=models.TextField( + blank=True, + help_text="Content body that was moderated (for content moderation)", + null=True, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="classification", + field=models.CharField( + blank=True, + choices=[("spam", "Spam"), ("spam_or_scam", "Spam or Scam")], + help_text="AI classification result", + max_length=20, + null=True, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="classifier_output", + field=models.JSONField( + blank=True, help_text="Full output from the AI classifier", null=True + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="moderator", + field=models.ForeignKey( + blank=True, + help_text="Human moderator who performed or overrode the action", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="audit_log_actions_performed", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="original_author", + field=models.ForeignKey( + blank=True, + help_text="Original author of the moderated content", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="moderated_content", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="reasoning", + field=models.TextField( + blank=True, help_text="AI reasoning for the decision", null=True + ), + ), + migrations.AlterField( + model_name="moderationauditlog", + name="timestamp", + field=models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + help_text="When the moderation action was taken", + ), + ), + migrations.AddField( + model_name="moderationauditlog", + name="source", + field=models.CharField( + choices=[ + ("human", "Human Moderator"), + ("ai", "AI Classifier"), + ("system", "System/Automated"), + ], + db_index=True, + default="ai", + help_text="Who initiated the moderation action", + max_length=20, + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["action_type", "-timestamp"], + name="forum_moder_action__32bd31_idx", + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["source", "-timestamp"], name="forum_moder_source_cf1224_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["target_user", "-timestamp"], + name="forum_moder_target__cadf75_idx", + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["original_author", "-timestamp"], + name="forum_moder_origina_6bb4d3_idx", + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["moderator", "-timestamp"], + name="forum_moder_moderat_2c467c_idx", + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["course_id", "-timestamp"], + name="forum_moder_course__9cbd6e_idx", + ), + ), + migrations.RunPython( + populate_source_with_ai, + reverse_populate_source, + ), + ] diff --git a/forum/migrations/0008_discussionmuterecord_and_more.py b/forum/migrations/0008_discussionmuterecord_and_more.py new file mode 100644 index 00000000..d2de62d2 --- /dev/null +++ b/forum/migrations/0008_discussionmuterecord_and_more.py @@ -0,0 +1,181 @@ +# Generated by Django 5.2.10 on 2026-03-15 05:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forum", "0007_add_discussion_ban_models"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DiscussionMuteRecord", + 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)), + ], + options={ + "db_table": "forum_discussion_user_mute", + }, + ), + migrations.AlterField( + model_name="moderationauditlog", + name="action_type", + field=models.CharField( + choices=[ + ("ban_user", "Ban User"), + ("ban_reactivate", "Ban Reactivated"), + ("unban_user", "Unban User"), + ("ban_exception", "Ban Exception Created"), + ("bulk_delete", "Bulk Delete"), + ("flagged", "Content Flagged"), + ("soft_deleted", "Content Soft Deleted"), + ("approved", "Content Approved"), + ("no_action", "No Action Taken"), + ("flagged", "Content Flagged"), + ("soft_deleted", "Content Soft Deleted"), + ("no_action", "No Action Taken"), + ("mute", "Mute"), + ("unmute", "Unmute"), + ("mute_and_report", "Mute and Report"), + ], + db_index=True, + default="no_action", + help_text="Type of moderation action taken", + max_length=50, + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["original_author"], name="forum_moder_origina_c51089_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["moderator"], name="forum_moder_moderat_c62a1c_idx" + ), + ), + migrations.AddIndex( + model_name="moderationauditlog", + index=models.Index( + fields=["course_id"], name="forum_moder_course__a3357b_idx" + ), + ), + migrations.AddField( + model_name="discussionmuterecord", + name="muted_by", + field=models.ForeignKey( + help_text="User performing the mute", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_muted_users", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="discussionmuterecord", + name="muted_user", + field=models.ForeignKey( + help_text="User being muted", + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_muted_by_users", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="discussionmuterecord", + name="unmuted_by", + field=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, + ), + ), + migrations.AddIndex( + model_name="discussionmuterecord", + index=models.Index( + fields=["muted_user", "course_id", "is_active"], + name="forum_discu_muted_u_2d9bf1_idx", + ), + ), + migrations.AddIndex( + model_name="discussionmuterecord", + index=models.Index( + fields=["muted_by", "course_id", "scope"], + name="forum_discu_muted_b_18ad23_idx", + ), + ), + migrations.AddIndex( + model_name="discussionmuterecord", + index=models.Index( + fields=["scope", "course_id", "is_active"], + name="forum_discu_scope_da97a8_idx", + ), + ), + migrations.AddConstraint( + model_name="discussionmuterecord", + 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="discussionmuterecord", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True), ("scope", "course")), + fields=("muted_user", "course_id"), + name="forum_unique_active_course_mute", + ), + ), + ] diff --git a/forum/serializers/bans.py b/forum/serializers/bans.py new file mode 100644 index 00000000..92b01d89 --- /dev/null +++ b/forum/serializers/bans.py @@ -0,0 +1,172 @@ +""" +Serializers for discussion ban operations. +""" + +from typing import Any + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +User = get_user_model() + + +class BanUserSerializer(serializers.Serializer): # type: ignore[type-arg] + """ + Serializer for banning a user from discussions. + """ + + user_id = serializers.IntegerField(required=True, help_text="ID of the user to ban") + banned_by_id = serializers.IntegerField( + required=True, help_text="ID of the moderator performing the ban" + ) + course_id = serializers.CharField( + required=False, allow_null=True, help_text="Course ID for course-level bans" + ) + org_key = serializers.CharField( + required=False, allow_null=True, help_text="Organization key for org-level bans" + ) + scope = serializers.ChoiceField( + choices=["course", "organization"], + default="course", + help_text="Ban scope: 'course' or 'organization'", + ) + reason = serializers.CharField( + required=False, allow_blank=True, help_text="Reason for the ban (optional)" + ) + + def create(self, validated_data: dict[str, Any]) -> Any: + """Not implemented - use API function instead.""" + raise NotImplementedError("Use ban_user() API function instead") + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Not implemented - bans are created, not updated.""" + raise NotImplementedError("Bans cannot be updated") + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + """Validate that required fields are present based on scope.""" + scope = attrs.get("scope", "course") + + if scope == "course" and not attrs.get("course_id"): + raise serializers.ValidationError( + {"course_id": "course_id is required for course-level bans"} + ) + + if scope == "organization" and not attrs.get("org_key"): + raise serializers.ValidationError( + {"org_key": "org_key is required for organization-level bans"} + ) + + return attrs + + +class UnbanUserSerializer(serializers.Serializer): # type: ignore[type-arg] + """ + Serializer for unbanning a user from discussions. + """ + + unbanned_by_id = serializers.IntegerField( + required=True, help_text="ID of the moderator performing the unban" + ) + + def create(self, validated_data: dict[str, Any]) -> Any: + """Not implemented - use API function instead.""" + raise NotImplementedError("Use unban_user() API function instead") + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Not implemented - use API function instead.""" + raise NotImplementedError("Use unban_user() API function instead") + + def validate_unbanned_by_id(self, value: int) -> int: + """Validate that the moderator exists.""" + try: + User.objects.get(id=value) + except User.DoesNotExist as exc: + raise serializers.ValidationError("Moderator user not found") from exc + return value + + course_id = serializers.CharField( + required=False, + allow_null=True, + help_text="Course ID for creating an exception to org-level ban", + ) + reason = serializers.CharField( + required=False, allow_blank=True, help_text="Reason for unbanning (optional)" + ) + + +class BannedUserResponseSerializer(serializers.Serializer): # type: ignore[type-arg] + """ + Serializer for banned user data in responses (read-only). + """ + + id = serializers.IntegerField(read_only=True) + + def create(self, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + user = serializers.DictField(read_only=True) + course_id = serializers.CharField(read_only=True, allow_null=True) + org_key = serializers.CharField(read_only=True, allow_null=True) + scope = serializers.CharField(read_only=True) + reason = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + banned_at = serializers.DateTimeField(read_only=True, allow_null=True) + banned_by = serializers.DictField(read_only=True, allow_null=True) + unbanned_at = serializers.DateTimeField(read_only=True, allow_null=True) + unbanned_by = serializers.DictField(read_only=True, allow_null=True) + + +class BannedUsersListSerializer(serializers.Serializer): # type: ignore[type-arg] + """ + Serializer for listing banned users with filtering options (read-only). + """ + + course_id = serializers.CharField( + required=False, allow_null=True, help_text="Filter by course ID" + ) + org_key = serializers.CharField( + required=False, allow_null=True, help_text="Filter by organization key" + ) + include_inactive = serializers.BooleanField( + default=False, help_text="Include inactive (unbanned) users" + ) + scope = serializers.ChoiceField( + choices=["course", "organization"], + required=False, + allow_null=True, + help_text="Filter by ban scope: 'course' or 'organization'", + ) + + def create(self, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + +class UnbanResponseSerializer(serializers.Serializer): # type: ignore[type-arg] + """ + Serializer for unban operation response (read-only). + """ + + status = serializers.CharField(read_only=True) + + def create(self, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Not implemented - read-only serializer.""" + raise NotImplementedError("Read-only serializer") + + message = serializers.CharField(read_only=True) + exception_created = serializers.BooleanField(read_only=True) + ban = BannedUserResponseSerializer(read_only=True) + exception = serializers.DictField(read_only=True, allow_null=True) diff --git a/forum/serializers/contents.py b/forum/serializers/contents.py index d01f69eb..bb4dcbd7 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -55,6 +55,7 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): edit_history (list): A list of previous versions of the content. closed (bool): Whether the content is closed for further interactions. type (str): The type of content (e.g., "post", "comment"). + is_spam (bool): Whether the content was flagged as spam by AI moderation. """ id = serializers.CharField(source="_id") @@ -76,6 +77,10 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): edit_history = EditHistorySerializer(default=[], many=True) closed = serializers.BooleanField(default=False) type = serializers.CharField() + is_spam = serializers.BooleanField(default=False) + is_deleted = serializers.BooleanField(default=False) + deleted_at = CustomDateTimeField(allow_null=True, required=False) + deleted_by = serializers.CharField(allow_null=True, required=False) def create(self, validated_data: dict[str, Any]) -> Any: """Raise NotImplementedError""" diff --git a/forum/toggles.py b/forum/toggles.py index 62616cc4..d4f031dc 100644 --- a/forum/toggles.py +++ b/forum/toggles.py @@ -5,6 +5,7 @@ FORUM_V2_WAFFLE_FLAG_NAMESPACE = "forum_v2" +DISCUSSION_WAFFLE_FLAG_NAMESPACE = "discussions" # .. toggle_name: forum_v2.enable_mysql_backend # .. toggle_implementation: CourseWaffleFlag @@ -16,3 +17,39 @@ ENABLE_MYSQL_BACKEND = CourseWaffleFlag( f"{FORUM_V2_WAFFLE_FLAG_NAMESPACE}.enable_mysql_backend", __name__ ) + +# .. toggle_name: discussions.enable_ai_moderation +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable AI moderation for discussions. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2025-10-29 +# .. toggle_target_removal_date: 2026-06-29 +ENABLE_AI_MODERATION = CourseWaffleFlag( + f"{DISCUSSION_WAFFLE_FLAG_NAMESPACE}.enable_ai_moderation", __name__ +) + +# .. toggle_name: discussions.enable_ai_auto_delete_spam +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable AI auto delete spam for discussions. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2026-02-05 +# .. toggle_target_removal_date: 2026-06-29 +ENABLE_AI_AUTO_DELETE_SPAM = CourseWaffleFlag( + f"{DISCUSSION_WAFFLE_FLAG_NAMESPACE}.enable_ai_auto_delete_spam", __name__ +) + + +def is_ai_auto_delete_spam_enabled(course_key): # type: ignore[no-untyped-def] + """ + Check if AI auto delete spam is enabled for the given course. + """ + return ENABLE_AI_AUTO_DELETE_SPAM.is_enabled(course_key) + + +def is_ai_moderation_enabled(course_key): # type: ignore[no-untyped-def] + """ + Check if AI moderation is enabled for the given course. + """ + return ENABLE_AI_MODERATION.is_enabled(course_key) diff --git a/forum/urls.py b/forum/urls.py index ee23f70e..c04cafb5 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -4,6 +4,12 @@ from django.urls import include, path +from forum.views.bans import ( + BanDetailAPIView, + BannedUsersAPIView, + BanUserAPIView, + UnbanUserAPIView, +) from forum.views.commentables import CommentablesCountAPIView from forum.views.comments import CommentsAPIView, CreateThreadCommentAPIView from forum.views.flags import CommentFlagAPIView, ThreadFlagAPIView @@ -116,6 +122,27 @@ UserCreateAPIView.as_view(), name="create-user", ), + # Ban/Unban user APIs + path( + "users/bans", + BanUserAPIView.as_view(), + name="ban-user", + ), + path( + "users/bans/", + BanDetailAPIView.as_view(), + name="ban-detail", + ), + path( + "users/bans//unban", + UnbanUserAPIView.as_view(), + name="unban-user", + ), + path( + "users/banned", + BannedUsersAPIView.as_view(), + name="banned-users-list", + ), path( "users/", UserAPIView.as_view(), @@ -151,13 +178,6 @@ UserRetireAPIView.as_view(), name="user-retire", ), - # Proxy view for various API endpoints - # Uncomment to redirect remaining API calls to the V1 API. - # path( - # "", - # ForumProxyAPIView.as_view(), - # name="forum_proxy", - # ), ] urlpatterns = [ diff --git a/forum/views/bans.py b/forum/views/bans.py new file mode 100644 index 00000000..51691a62 --- /dev/null +++ b/forum/views/bans.py @@ -0,0 +1,248 @@ +""" +API Views for managing discussion bans. +""" + +import logging + +from django.contrib.auth import get_user_model +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.bans import ban_user, get_ban, get_banned_users, unban_user +from forum.backends.mysql.models import DiscussionBan +from forum.serializers.bans import ( + BannedUserResponseSerializer, + BannedUsersListSerializer, + BanUserSerializer, + UnbanUserSerializer, +) + +User = get_user_model() +log = logging.getLogger(__name__) + + +class BanUserAPIView(APIView): + """ + API View to ban a user from discussions. + + Endpoint: POST /api/v2/users/bans + + Request Body: + { + "user_id": "123", + "banned_by_id": "456", + "scope": "course", # or "organization" + "course_id": "course-v1:edX+DemoX+Demo_Course", # required for course scope + "org_key": "edX", # required for organization scope + "reason": "Posting spam content" + } + + Response: + { + "id": 1, + "user": {"id": 123, "username": "learner", "email": "learner@example.com"}, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "org_key": "edX", + "scope": "course", + "reason": "Posting spam content", + "is_active": true, + "banned_at": "2024-01-15T10:30:00Z", + "banned_by": {"id": 456, "username": "moderator"} + } + """ + + permission_classes = (AllowAny,) + + def post(self, request: Request) -> Response: + """Ban a user from discussions.""" + serializer = BanUserSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + validated_data = serializer.validated_data.copy() + # Convert user IDs to User objects + user_id = validated_data.pop("user_id") + banned_by_id = validated_data.pop("banned_by_id") + user = User.objects.get(id=user_id) + banned_by = User.objects.get(id=banned_by_id) + + ban_data = ban_user(user=user, banned_by=banned_by, **validated_data) + return Response(ban_data, status=status.HTTP_201_CREATED) + except (ValueError, TypeError) as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except User.DoesNotExist: + return Response( + {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.exception("Error banning user: %s", str(e)) + return Response( + {"error": "Failed to ban user"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class UnbanUserAPIView(APIView): + """ + API View to unban a user from discussions. + + Endpoint: POST /api/v2/users/bans//unban + + Request Body: + { + "unbanned_by_id": "456", + "course_id": "course-v1:edX+DemoX+Demo_Course", # optional, for org-level ban exceptions + "reason": "User appeal approved" + } + + Response: + { + "status": "success", + "message": "User learner unbanned successfully", + "exception_created": false, + "ban": {...}, + "exception": null + } + """ + + permission_classes = (AllowAny,) + + def post(self, request: Request, ban_id: int) -> Response: + """Unban a user from discussions.""" + serializer = UnbanUserSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + validated_data = serializer.validated_data.copy() + # Convert unbanned_by_id to User object + unbanned_by_id = validated_data.pop("unbanned_by_id") + unbanned_by = User.objects.get(id=unbanned_by_id) + + unban_data = unban_user( + ban_id=ban_id, unbanned_by=unbanned_by, **validated_data + ) + return Response(unban_data, status=status.HTTP_200_OK) + except ValueError as e: + if "not found" in str(e).lower(): + return Response({"error": str(e)}, status=status.HTTP_404_NOT_FOUND) + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except TypeError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except DiscussionBan.DoesNotExist: + return Response( + {"error": f"Active ban with id {ban_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + except User.DoesNotExist: + return Response( + {"error": "Moderator user not found"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.exception("Error unbanning user: %s", str(e)) + return Response( + {"error": "Failed to unban user"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class BannedUsersAPIView(APIView): + """ + API View to list banned users. + + Endpoint: GET /api/v2/users/bans + + Query Parameters: + - course_id (optional): Filter by course ID + - org_key (optional): Filter by organization key + - include_inactive (optional): Include inactive bans (default: false) + + Response: + [ + { + "id": 1, + "user": {"id": 123, "username": "learner", "email": "learner@example.com"}, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "org_key": "edX", + "scope": "course", + "reason": "Posting spam content", + "is_active": true, + "banned_at": "2024-01-15T10:30:00Z", + "banned_by": {"id": 456, "username": "moderator"}, + "unbanned_at": null, + "unbanned_by": null + } + ] + """ + + permission_classes = (AllowAny,) + + def get(self, request: Request) -> Response: + """Get list of banned users.""" + serializer = BannedUsersListSerializer(data=request.query_params) + + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + banned_users = get_banned_users(**serializer.validated_data) + response_serializer = BannedUserResponseSerializer(banned_users, many=True) + return Response(response_serializer.data, status=status.HTTP_200_OK) + except (ValueError, TypeError) as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.exception("Error fetching banned users: %s", str(e)) + return Response( + {"error": "Failed to fetch banned users"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class BanDetailAPIView(APIView): + """ + API View to get details of a specific ban. + + Endpoint: GET /api/v2/users/bans/ + + Response: + { + "id": 1, + "user": {"id": 123, "username": "learner", "email": "learner@example.com"}, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "org_key": "edX", + "scope": "course", + "reason": "Posting spam content", + "is_active": true, + "banned_at": "2024-01-15T10:30:00Z", + "banned_by": {"id": 456, "username": "moderator"}, + "unbanned_at": null, + "unbanned_by": null + } + """ + + permission_classes = (AllowAny,) + + def get(self, request: Request, ban_id: int) -> Response: + """Get details of a specific ban.""" + try: + ban_data = get_ban(ban_id) + if ban_data is None: + return Response( + {"error": f"Ban with id {ban_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(ban_data, status=status.HTTP_200_OK) + except (ValueError, TypeError) as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: # pylint: disable=broad-exception-caught + log.exception("Error fetching ban details: %s", str(e)) + return Response( + {"error": "Failed to fetch ban details"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/forum/views/comments.py b/forum/views/comments.py index ed90507c..2dd4bf2b 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, @@ -142,7 +142,7 @@ def delete(self, request: Request, comment_id: str) -> Response: request (Request): The incoming request. comment_id: The ID of the comment to be deleted. Body: - Empty. + deleted_by: Optional ID of the user performing the delete (defaults to authenticated user). Response: The details of the comment that is deleted. """ diff --git a/requirements/base.in b/requirements/base.in index 7fc1fced..ab0c2866 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,6 +5,7 @@ Django # Web application framework beautifulsoup4 +django-model-utils # For TimeStampedModel djangorestframework openedx-atlas requests diff --git a/tests/e2e/test_users.py b/tests/e2e/test_users.py index de1119e2..45323608 100644 --- a/tests/e2e/test_users.py +++ b/tests/e2e/test_users.py @@ -96,6 +96,9 @@ def build_structure_and_response( "threads": 0, "responses": 0, "replies": 0, + "deleted_threads": 0, + "deleted_responses": 0, + "deleted_replies": 0, } for author in authors } @@ -505,8 +508,11 @@ def test_handles_deleting_replies( # Thread count should stay the same assert new_stats is not None assert new_stats["threads"] == stats["threads"] - assert new_stats["responses"] == stats["responses"] - assert new_stats["replies"] == stats["replies"] - 1 + # Deleting a reply decrements either responses or replies (backend-specific) + # Total comment count (responses + replies) should decrease by 1 + assert (new_stats["responses"] + new_stats["replies"]) == ( + stats["responses"] + stats["replies"] - 1 + ) def test_handles_removing_flags( @@ -618,7 +624,12 @@ def test_update_user_stats(api_client: APIClient, patched_get_backend: Any) -> N # Sort the data for expected result (threads, responses, replies) expected_result = sorted( expected_data.values(), - key=lambda val: (val["threads"], val["responses"], val["replies"]), + key=lambda val: ( + val["threads"], + val["responses"], + val["replies"], + val["username"], + ), reverse=True, ) diff --git a/tests/test_ai_moderation.py b/tests/test_ai_moderation.py new file mode 100644 index 00000000..508e6f2c --- /dev/null +++ b/tests/test_ai_moderation.py @@ -0,0 +1,485 @@ +"""Tests for AI moderation functionality.""" + +import sys +from typing import Any +from unittest.mock import Mock, MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model + +from forum.ai_moderation import AIModerationService, moderate_and_flag_spam +from forum.backends.mysql.models import ModerationAuditLog +from forum.utils import ForumV2RequestError + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +# Mock openedx module to prevent import errors +if "openedx" not in sys.modules: + # Create a mock CourseWaffleFlag class + class MockCourseWaffleFlag: + """Mock implementation of openedx CourseWaffleFlag for testing.""" + + def __init__(self, flag_name: str, module_name: str) -> None: + self.flag_name = flag_name + self.module_name = module_name + + def is_enabled(self, _course_key: Any) -> bool: + # This will be overridden by our fixture patches + return False + + mock_openedx = MagicMock() + mock_waffle_utils = MagicMock() + mock_waffle_utils.CourseWaffleFlag = MockCourseWaffleFlag + + sys.modules["openedx"] = mock_openedx + sys.modules["openedx.core"] = MagicMock() + sys.modules["openedx.core.djangoapps"] = MagicMock() + sys.modules["openedx.core.djangoapps.waffle_utils"] = mock_waffle_utils + + +@pytest.fixture +def mock_ai_moderation_settings() -> Any: + """Mock AI moderation settings.""" + with patch("forum.ai_moderation.settings") as mock_settings: + mock_settings.AI_MODERATION_API_URL = "http://test-api.example.com" + mock_settings.AI_MODERATION_API_KEY = "test-api-key" + mock_settings.AI_MODERATION_USER_ID = "999" + yield mock_settings + + +@pytest.fixture +def mock_waffle_flags() -> Any: + """Mock waffle flags for AI moderation.""" + # Now we can safely import forum.toggles since openedx is mocked + import forum.toggles # pylint: disable=import-outside-toplevel + + mock_enabled = Mock(return_value=True) + mock_auto_delete = Mock(return_value=True) + + with patch.object( + forum.toggles, "is_ai_moderation_enabled", mock_enabled + ), patch.object(forum.toggles, "is_ai_auto_delete_spam_enabled", mock_auto_delete): + yield {"enabled": mock_enabled, "auto_delete": mock_auto_delete} + + +@pytest.fixture +def ai_service( + mock_ai_moderation_settings: Any, # pylint: disable=redefined-outer-name,unused-argument +) -> AIModerationService: + """Create an AI moderation service instance.""" + return AIModerationService() # type: ignore[no-untyped-call] + + +@pytest.fixture +def sample_thread_content() -> dict[str, Any]: + """Create sample thread content for testing.""" + return { + "_id": "thread123", + "_type": "CommentThread", + "course_id": "course-v1:edX+DemoX+Demo", + "title": "Test Thread", + "body": "This is test content", + "author_id": "1", + "author_username": "testuser", + } + + +@pytest.fixture +def sample_comment_content() -> dict[str, Any]: + """Create sample comment content for testing.""" + return { + "_id": "comment456", + "_type": "Comment", + "course_id": "course-v1:edX+DemoX+Demo", + "body": "This is a test comment", + "author_id": "1", + "author_username": "testuser", + "comment_thread_id": "thread123", + } + + +class TestAIModerationAutoDelete: # pylint: disable=redefined-outer-name,unused-argument + """Tests for AI moderation auto-delete functionality.""" + + def test_auto_delete_triggered_when_enabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is triggered when waffle flag is enabled.""" + # Mock API response indicating spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "This content is spam", "confidence_score": 0.95}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was called + mock_delete.assert_called_once_with(sample_thread_content) + + # Verify actions_taken includes both flagged and soft_deleted + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + assert result["is_spam"] is True + + def test_auto_delete_not_triggered_when_disabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is NOT triggered when waffle flag is disabled.""" + # Disable auto-delete flag + mock_waffle_flags["auto_delete"].return_value = False + + # Mock API response indicating spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "This content is spam", "confidence_score": 0.95}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was NOT called + mock_delete.assert_not_called() + + # Verify actions_taken includes only flagged + assert "flagged" in result["actions_taken"] + assert "soft_deleted" not in result["actions_taken"] + assert result["is_spam"] is True + + def test_auto_delete_not_triggered_for_non_spam( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that auto-delete is NOT triggered for non-spam content.""" + # Mock API response indicating NOT spam + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "not_spam", ' + '"reasoning": "This is legitimate content", ' + '"confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "legitimate content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify auto-delete was NOT called + mock_delete.assert_not_called() + + # Verify no actions taken + assert result["actions_taken"] == ["no_action"] + assert result["is_spam"] is False + + def test_actions_taken_reflects_flagged_only_when_delete_disabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Test that actions_taken correctly reflects flagging without deletion.""" + # Disable auto-delete + mock_waffle_flags["auto_delete"].return_value = False + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response): + result = ai_service.moderate_and_flag_content( + "spam content", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert result["actions_taken"] == ["flagged"] + assert result["flagged"] is True + + def test_actions_taken_reflects_both_when_delete_enabled( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Test that actions_taken correctly reflects both flagging and deletion.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ): + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + assert len(result["actions_taken"]) == 2 + + +class TestAIModerationErrorHandling: # pylint: disable=redefined-outer-name,unused-argument + """Tests for error handling in AI moderation auto-delete.""" + + def test_deletion_failure_after_successful_flagging( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that flagging succeeds even if deletion fails.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, + "_delete_content", + side_effect=ForumV2RequestError("Delete failed"), + ): + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Flagging should still succeed + assert result["is_spam"] is True + assert "flagged" in result["actions_taken"] + # soft_deleted should not be in actions since deletion failed + assert "soft_deleted" not in result["actions_taken"] + + def test_flagging_failure_prevents_deletion( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that if flagging fails, deletion is not attempted.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + backend.flag_content_as_spam.side_effect = ValueError("Flag failed") + + with patch("requests.post", return_value=mock_response), patch.object( + ai_service, "_delete_content" + ) as mock_delete: + + result = ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Delete should not be called if flagging fails + mock_delete.assert_not_called() + assert result["actions_taken"] == ["no_action"] + + +class TestDeleteContentMethod: # pylint: disable=redefined-outer-name,protected-access + """Tests for the _delete_content method.""" + + def test_delete_thread_calls_api_correctly( + self, + ai_service: AIModerationService, + sample_thread_content: dict[str, Any], + ) -> None: + """Test that deleting a thread calls the API layer correctly.""" + with patch("forum.api.threads.delete_thread") as mock_delete_thread: + ai_service._delete_content(sample_thread_content) + + mock_delete_thread.assert_called_once_with( + "thread123", + course_id="course-v1:edX+DemoX+Demo", + deleted_by="999", + ) + + def test_delete_comment_calls_api_correctly( + self, + ai_service: AIModerationService, + sample_comment_content: dict[str, Any], + ) -> None: + """Test that deleting a comment calls the API layer correctly.""" + with patch("forum.api.comments.delete_comment") as mock_delete_comment: + ai_service._delete_content(sample_comment_content) + + mock_delete_comment.assert_called_once_with( + "comment456", + course_id="course-v1:edX+DemoX+Demo", + deleted_by="999", + ) + + def test_delete_handles_api_errors( + self, + ai_service: AIModerationService, + sample_thread_content: dict[str, Any], + ) -> None: + """Test that deletion errors propagate to caller.""" + with patch("forum.api.threads.delete_thread") as mock_delete_thread: + mock_delete_thread.side_effect = ForumV2RequestError("API Error") + + # Should raise exception to caller + with pytest.raises(ForumV2RequestError): + ai_service._delete_content(sample_thread_content) + + +class TestModerateAndFlagSpamFunction: # pylint: disable=redefined-outer-name + """Tests for the module-level moderate_and_flag_spam function.""" + + def test_moderate_and_flag_spam_with_auto_delete( # pylint: disable=unused-argument + self, + mock_ai_moderation_settings: Any, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test the module-level function with auto-delete enabled.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + # Create instance with mocked settings already active + test_service: AIModerationService = AIModerationService() # type: ignore[no-untyped-call] + + with patch("requests.post", return_value=mock_response), patch( + "forum.api.threads.delete_thread" + ), patch("forum.ai_moderation.ai_moderation_service", test_service): + + result = moderate_and_flag_spam( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + assert result["is_spam"] is True + assert "flagged" in result["actions_taken"] + assert "soft_deleted" in result["actions_taken"] + + +class TestAuditLogging: # pylint: disable=redefined-outer-name,unused-argument + """Tests for audit logging with auto-delete.""" + + def test_audit_log_created_for_auto_deleted_content( + self, + ai_service: AIModerationService, + mock_ai_moderation_settings: Any, + mock_waffle_flags: dict[str, Mock], + sample_thread_content: dict[str, Any], + ) -> None: + """Test that audit log is created with correct actions for auto-deleted content.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + { + "content": '{"classification": "spam", "reasoning": "Spam detected", "confidence_score": 0.9}' + } + ] + + backend = Mock() + user = User.objects.create(username="testuser") + sample_thread_content["author_id"] = str(user.pk) + + with patch("requests.post", return_value=mock_response), patch( + "forum.api.threads.delete_thread" + ): + + ai_service.moderate_and_flag_content( + "spam content", + sample_thread_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + # Verify audit log was created + audit_logs = ModerationAuditLog.objects.filter(body="This is test content") + assert audit_logs.exists() + + audit_log = audit_logs.first() + assert audit_log is not None + assert "flagged" in audit_log.actions_taken + assert "soft_deleted" in audit_log.actions_taken diff --git a/tests/test_api_bans.py b/tests/test_api_bans.py new file mode 100644 index 00000000..d1fbe25b --- /dev/null +++ b/tests/test_api_bans.py @@ -0,0 +1,567 @@ +""" +Tests for forum ban API functions. + +These tests verify the ban API functions work correctly with the MySQL backend. +Tests include: ban_user, unban_user, get_banned_users, get_ban, is_user_banned, +get_user_ban_scope, get_banned_usernames, create_audit_log. +""" + +# mypy: ignore-errors +# pylint: disable=redefined-outer-name + +import pytest +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey + +from forum import api as forum_api +from forum.backends.mysql.models import ( + DiscussionBan, + DiscussionBanException, + ModerationAuditLog, +) + +User = get_user_model() + + +@pytest.fixture +def test_users(db): # pylint: disable=unused-argument + """Create test users.""" + return { + "learner": User.objects.create_user( + username="learner", email="learner@example.com", password="password" + ), + "moderator": User.objects.create_user( + username="moderator", + email="moderator@example.com", + password="password", + is_staff=True, + ), + "another_learner": User.objects.create_user( + username="another_learner", + email="another@example.com", + password="password", + ), + } + + +@pytest.fixture +def test_course_keys(): + """Create test course keys.""" + return { + "course1": CourseKey.from_string("course-v1:edX+DemoX+2024"), + "course2": CourseKey.from_string("course-v1:edX+DemoX+2025"), + "mitx_course": CourseKey.from_string("course-v1:MITx+Python+2024"), + } + + +@pytest.mark.django_db +class TestBanUserAPI: + """Tests for ban_user() API function.""" + + def test_ban_user_course_level(self, test_users, test_course_keys): + """Test banning a user at course level.""" + result = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Posting spam", + ) + + assert result["id"] is not None + assert result["user"]["id"] == test_users["learner"].id + assert result["user"]["username"] == "learner" + assert result["scope"] == "course" + assert result["course_id"] == str(test_course_keys["course1"]) + assert result["is_active"] is True + assert result["reason"] == "Posting spam" + assert result["banned_by"]["id"] == test_users["moderator"].id + + # Verify in database + ban = DiscussionBan.objects.get(user=test_users["learner"]) + assert ban.scope == "course" + assert ban.is_active is True + + def test_ban_user_org_level(self, test_users, test_course_keys): + """Test banning a user at organization level.""" + result = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Repeated violations", + ) + + assert result["scope"] == "organization" + assert result["org_key"] == "edX" + assert result["course_id"] is None + + # Verify in database + ban = DiscussionBan.objects.get(user=test_users["learner"]) + assert ban.scope == "organization" + assert ban.org_key == "edX" + assert ban.course_id is None + + def test_ban_user_reactivates_inactive_ban(self, test_users, test_course_keys): + """Test that banning reactivates an inactive ban.""" + # Create inactive ban + DiscussionBan.objects.create( + user=test_users["learner"], + course_id=test_course_keys["course1"], + scope="course", + banned_by=test_users["moderator"], + reason="Old reason", + is_active=False, + ) + + # Ban again + result = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="New reason", + ) + + assert result["is_active"] is True + assert result["reason"] == "New reason" + assert result.get("reactivated") is True + + # Only one ban should exist + assert DiscussionBan.objects.filter(user=test_users["learner"]).count() == 1 + + +@pytest.mark.django_db +class TestUnbanUserAPI: + """Tests for unban_user() API function.""" + + def test_unban_user_course_level(self, test_users, test_course_keys): + """Test unbanning a user at course level.""" + # Create ban first + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Test", + ) + + # Unban + result = forum_api.unban_user( + user=test_users["learner"], + unbanned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + ) + + assert result["status"] == "success" + + # Verify ban is inactive + ban = DiscussionBan.objects.get(user=test_users["learner"]) + assert ban.is_active is False + assert ban.unbanned_by == test_users["moderator"] + + def test_unban_user_org_level(self, test_users, test_course_keys): + """Test unbanning a user at organization level.""" + # Create org ban + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + # Unban - don't pass course_id to fully deactivate org ban + result = forum_api.unban_user( + user=test_users["learner"], + unbanned_by=test_users["moderator"], + scope="organization", + ) + + assert result["status"] == "success" + + # Verify ban is inactive + ban = DiscussionBan.objects.get(user=test_users["learner"]) + assert ban.is_active is False + + +@pytest.mark.django_db +class TestGetBannedUsersAPI: + """Tests for get_banned_users() API function.""" + + def test_get_banned_users_course_and_org(self, test_users, test_course_keys): + """Test getting banned users returns both course and org bans.""" + # Create course ban + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Course ban", + ) + + # Create org ban for different user + forum_api.ban_user( + user=test_users["another_learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Org ban", + ) + + result = forum_api.get_banned_users(course_id=test_course_keys["course1"]) + + assert len(result) == 2 + usernames = {ban["user"]["username"] for ban in result} + assert "learner" in usernames + assert "another_learner" in usernames + + def test_get_banned_users_filter_by_scope(self, test_users, test_course_keys): + """Test filtering banned users by scope.""" + # Create both types of bans + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Course ban", + ) + + forum_api.ban_user( + user=test_users["another_learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Org ban", + ) + + # Filter for course-level only + course_bans = forum_api.get_banned_users( + course_id=test_course_keys["course1"], scope="course" + ) + assert len(course_bans) == 1 + assert course_bans[0]["user"]["username"] == "learner" + + # Filter for org-level only + org_bans = forum_api.get_banned_users( + course_id=test_course_keys["course1"], scope="organization" + ) + assert len(org_bans) == 1 + assert org_bans[0]["user"]["username"] == "another_learner" + + def test_get_banned_users_excludes_org_bans_with_course_exceptions( + self, test_users, test_course_keys + ): + """Test that org-level bans with course exceptions are excluded from course's banned users list.""" + # Create org-level ban + ban = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Org-wide ban", + ) + ban_id = ban["id"] + + # Initially, user should appear in banned users list for course1 + result = forum_api.get_banned_users(course_id=test_course_keys["course1"]) + assert len(result) == 1 + assert result[0]["user"]["username"] == "learner" + + # Create course exception (simulating course-level unban of org ban) + DiscussionBanException.objects.create( + ban_id=ban_id, + course_id=test_course_keys["course1"], + unbanned_by=test_users["moderator"], + reason="Exception for course1", + ) + + # Now user should NOT appear in banned users list for course1 + result = forum_api.get_banned_users(course_id=test_course_keys["course1"]) + assert len(result) == 0 + + # But should still appear for course2 (no exception there) + result = forum_api.get_banned_users(course_id=test_course_keys["course2"]) + assert len(result) == 1 + assert result[0]["user"]["username"] == "learner" + + +@pytest.mark.django_db +class TestGetBanAPI: + """Tests for get_ban() API function.""" + + def test_get_ban_exists(self, test_users, test_course_keys): + """Test getting an existing ban.""" + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Test", + ) + + result = forum_api.get_ban( + user=test_users["learner"], + course_id=test_course_keys["course1"], + scope="course", + ) + + assert result is not None + assert result["user"]["id"] == test_users["learner"].id + assert result["scope"] == "course" + assert result["is_active"] is True + + def test_get_ban_not_exists(self, test_users, test_course_keys): + """Test getting a non-existent ban returns None.""" + result = forum_api.get_ban( + user=test_users["learner"], + course_id=test_course_keys["course1"], + scope="course", + ) + + assert result is None + + +@pytest.mark.django_db +class TestIsUserBannedAPI: + """Tests for is_user_banned() API function.""" + + def test_is_user_banned_course_level(self, test_users, test_course_keys): + """Test checking if user is banned at course level.""" + # Not banned initially + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course1"]) + is False + ) + + # Ban user + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Test", + ) + + # Now banned + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course1"]) + is True + ) + + def test_is_user_banned_org_level(self, test_users, test_course_keys): + """Test checking if user is banned at org level.""" + # Ban at org level + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + # Banned in all courses of same org + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course1"]) + is True + ) + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course2"]) + is True + ) + + # Not banned in different org + assert ( + forum_api.is_user_banned( + test_users["learner"], test_course_keys["mitx_course"] + ) + is False + ) + + def test_is_user_banned_with_exception(self, test_users, test_course_keys): + """Test ban exception allows user in specific course.""" + # Create org ban + ban_result = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + # Create exception for course2 + ban = DiscussionBan.objects.get(id=ban_result["id"]) + DiscussionBanException.objects.create( + ban=ban, + course_id=test_course_keys["course2"], + unbanned_by=test_users["moderator"], + reason="Exception", + ) + + # Still banned in course1 + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course1"]) + is True + ) + + # Not banned in course2 due to exception + assert ( + forum_api.is_user_banned(test_users["learner"], test_course_keys["course2"]) + is False + ) + + +@pytest.mark.django_db +class TestGetUserBanScopeAPI: + """Tests for get_user_ban_scope() API function.""" + + def test_get_user_ban_scope_course(self, test_users, test_course_keys): + """Test getting course-level ban scope.""" + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Test", + ) + + scope = forum_api.get_user_ban_scope( + test_users["learner"], test_course_keys["course1"] + ) + assert scope == "course" + + def test_get_user_ban_scope_organization(self, test_users, test_course_keys): + """Test getting organization-level ban scope.""" + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + scope = forum_api.get_user_ban_scope( + test_users["learner"], test_course_keys["course1"] + ) + assert scope == "organization" + + def test_get_user_ban_scope_not_banned(self, test_users, test_course_keys): + """Test getting scope for non-banned user returns None.""" + scope = forum_api.get_user_ban_scope( + test_users["learner"], test_course_keys["course1"] + ) + assert scope is None + + def test_get_user_ban_scope_with_exception(self, test_users, test_course_keys): + """Test scope returns None when exception exists.""" + # Create org ban + ban_result = forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + # Create exception + ban = DiscussionBan.objects.get(id=ban_result["id"]) + DiscussionBanException.objects.create( + ban=ban, + course_id=test_course_keys["course1"], + unbanned_by=test_users["moderator"], + reason="Exception", + ) + + scope = forum_api.get_user_ban_scope( + test_users["learner"], test_course_keys["course1"] + ) + assert scope is None + + +@pytest.mark.django_db +class TestGetBannedUsernamesAPI: + """Tests for get_banned_usernames() API function.""" + + def test_get_banned_usernames_course(self, test_users, test_course_keys): + """Test getting banned usernames for a course.""" + # Ban multiple users + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="course", + reason="Test", + ) + + forum_api.ban_user( + user=test_users["another_learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + usernames = forum_api.get_banned_usernames( + course_id=test_course_keys["course1"] + ) + + assert isinstance(usernames, set) + assert "learner" in usernames + assert "another_learner" in usernames + assert "moderator" not in usernames + + def test_get_banned_usernames_org_only(self, test_users, test_course_keys): + """Test getting banned usernames at org level.""" + forum_api.ban_user( + user=test_users["learner"], + banned_by=test_users["moderator"], + course_id=test_course_keys["course1"], + scope="organization", + reason="Test", + ) + + usernames = forum_api.get_banned_usernames(org_key="edX") + + assert "learner" in usernames + + +@pytest.mark.django_db +class TestCreateAuditLogAPI: + """Tests for create_audit_log() API function.""" + + def test_create_audit_log_ban_action(self, test_users, test_course_keys): + """Test creating audit log for ban action.""" + log = forum_api.create_audit_log( + action_type=ModerationAuditLog.ACTION_BAN, + target_user=test_users["learner"], + moderator=test_users["moderator"], + course_id=str(test_course_keys["course1"]), + scope="course", + reason="Posting spam", + metadata={"threads_deleted": 5, "comments_deleted": 10}, + ) + + assert log.id is not None + assert log.action_type == ModerationAuditLog.ACTION_BAN + assert log.target_user == test_users["learner"] + assert log.moderator == test_users["moderator"] + assert log.course_id == str(test_course_keys["course1"]) + assert log.scope == "course" + assert log.reason == "Posting spam" + assert log.metadata["threads_deleted"] == 5 + assert log.metadata["comments_deleted"] == 10 + + def test_create_audit_log_unban_action(self, test_users, test_course_keys): + """Test creating audit log for unban action.""" + log = forum_api.create_audit_log( + action_type=ModerationAuditLog.ACTION_UNBAN, + target_user=test_users["learner"], + moderator=test_users["moderator"], + course_id=str(test_course_keys["course1"]), + scope="course", + reason="Appeal granted", + ) + + assert log.action_type == ModerationAuditLog.ACTION_UNBAN + assert log.reason == "Appeal granted" diff --git a/tests/test_backends/test_mongodb/test_comments.py b/tests/test_backends/test_mongodb/test_comments.py index df750922..4b255054 100644 --- a/tests/test_backends/test_mongodb/test_comments.py +++ b/tests/test_backends/test_mongodb/test_comments.py @@ -32,10 +32,10 @@ def test_delete() -> None: invalid_id = "66dedf65a2e0d02feebde812" result = Comment().delete(invalid_id) - assert result == 0 + assert result == (0, 0) result = Comment().delete(comment_id) - assert result == 1 + assert result == (1, 0) comment_data = Comment().get(_id=comment_id) assert comment_data is None diff --git a/tests/test_backends/test_mysql/test_api.py b/tests/test_backends/test_mysql/test_api.py index d89a537f..e05a7d19 100644 --- a/tests/test_backends/test_mysql/test_api.py +++ b/tests/test_backends/test_mysql/test_api.py @@ -7,7 +7,13 @@ from django.contrib.auth import get_user_model from forum.backends.mysql.api import MySQLBackend as backend -from forum.backends.mysql.models import AbuseFlagger, CommentThread, CourseStat +from forum.backends.mysql.models import ( + AbuseFlagger, + Comment, + CommentThread, + CourseStat, + Subscription, +) from forum.serializers.thread import ThreadSerializer User = get_user_model() @@ -226,3 +232,538 @@ def test_filter_by_commentable_ids(self) -> None: assert threads["thread_count"] == 2 for thread in threads["collection"]: assert thread["commentable_id"] == "id_2" + + +# Bulk Delete and Count API Tests + + +@pytest.mark.django_db +def test_get_user_threads_count() -> None: + """Test counting user threads across multiple courses.""" + user = User.objects.create(username="testuser") + other_user = User.objects.create(username="otheruser") + + # Create threads for user in different courses + CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 1", + body="Body 1", + thread_type="discussion", + ) + CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 2", + body="Body 2", + thread_type="discussion", + ) + CommentThread.objects.create( + author=user, + course_id="course2", + title="Thread 3", + body="Body 3", + thread_type="discussion", + ) + # Create deleted thread (should not be counted) + CommentThread.objects.create( + author=user, + course_id="course1", + title="Deleted Thread", + body="Deleted", + thread_type="discussion", + is_deleted=True, + ) + # Create thread by other user (should not be counted) + CommentThread.objects.create( + author=other_user, + course_id="course1", + title="Other Thread", + body="Other", + thread_type="discussion", + ) + + # Test counting across specific courses + count = backend.get_user_threads_count(str(user.pk), ["course1", "course2"]) + assert count == 3 + + # Test counting in single course + count = backend.get_user_threads_count(str(user.pk), ["course1"]) + assert count == 2 + + # Test counting in course with no threads + count = backend.get_user_threads_count(str(user.pk), ["course3"]) + assert count == 0 + + +@pytest.mark.django_db +def test_get_user_comment_count() -> None: + """Test counting user comments across multiple courses.""" + user = User.objects.create(username="testuser") + other_user = User.objects.create(username="otheruser") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create comments for user in different courses + Comment.objects.create( + author=user, + course_id="course1", + body="Comment 1", + comment_thread=thread, + ) + Comment.objects.create( + author=user, + course_id="course1", + body="Comment 2", + comment_thread=thread, + ) + Comment.objects.create( + author=user, + course_id="course2", + body="Comment 3", + comment_thread=thread, + ) + # Create deleted comment (should not be counted) + Comment.objects.create( + author=user, + course_id="course1", + body="Deleted Comment", + comment_thread=thread, + is_deleted=True, + ) + # Create comment by other user (should not be counted) + Comment.objects.create( + author=other_user, + course_id="course1", + body="Other Comment", + comment_thread=thread, + ) + + # Test counting across specific courses + count = backend.get_user_comment_count(str(user.pk), ["course1", "course2"]) + assert count == 3 + + # Test counting in single course + count = backend.get_user_comment_count(str(user.pk), ["course1"]) + assert count == 2 + + # Test counting in course with no comments + count = backend.get_user_comment_count(str(user.pk), ["course3"]) + assert count == 0 + + +@pytest.mark.django_db +def test_delete_user_threads() -> None: + """Test bulk deletion of user threads.""" + user = User.objects.create(username="testuser") + other_user = User.objects.create(username="otheruser") + + # Create threads + thread1 = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 1", + body="Body 1", + thread_type="discussion", + ) + thread2 = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 2", + body="Body 2", + thread_type="discussion", + ) + thread3 = CommentThread.objects.create( + author=user, + course_id="course2", + title="Thread 3", + body="Body 3", + thread_type="discussion", + ) + # Already deleted thread (should be skipped) + deleted_thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Deleted Thread", + body="Deleted", + thread_type="discussion", + is_deleted=True, + ) + # Other user's thread (should not be deleted) + other_thread = CommentThread.objects.create( + author=other_user, + course_id="course1", + title="Other Thread", + body="Other", + thread_type="discussion", + ) + + # Delete threads + count = backend.delete_user_threads(str(user.pk), ["course1", "course2"]) + + # Verify count + assert count == 3 + + # Verify threads are soft-deleted + thread1.refresh_from_db() + thread2.refresh_from_db() + thread3.refresh_from_db() + assert thread1.is_deleted is True + assert thread2.is_deleted is True + assert thread3.is_deleted is True + + # Verify already deleted thread unchanged + deleted_thread.refresh_from_db() + assert deleted_thread.is_deleted is True + + # Verify other user's thread not deleted + other_thread.refresh_from_db() + assert other_thread.is_deleted is False + + +@pytest.mark.django_db +def test_delete_user_threads_with_subscriptions() -> None: + """Test that thread subscriptions are cleaned up during bulk delete.""" + user = User.objects.create(username="testuser") + subscriber = User.objects.create(username="subscriber") + + # Create thread with subscription + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread with Subscription", + body="Body", + thread_type="discussion", + ) + + # Create subscription + Subscription.objects.create( + subscriber=subscriber, + source=thread, + ) + + # Verify subscription exists + assert Subscription.objects.filter(source_object_id=thread.pk).count() == 1 + + # Delete threads + count = backend.delete_user_threads(str(user.pk), ["course1"]) + assert count == 1 + + # Verify subscription was deleted + assert Subscription.objects.filter(source_object_id=thread.pk).count() == 0 + + +@pytest.mark.django_db +def test_delete_user_threads_with_comments() -> None: + """Test that thread comments are soft-deleted during bulk thread delete.""" + user = User.objects.create(username="testuser") + commenter = User.objects.create(username="commenter") + + # Create thread + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create comments on the thread + response = Comment.objects.create( + author=commenter, + course_id="course1", + body="Response", + comment_thread=thread, + ) + reply = Comment.objects.create( + author=commenter, + course_id="course1", + body="Reply", + comment_thread=thread, + parent=response, + ) + + # Delete threads + count = backend.delete_user_threads(str(user.pk), ["course1"]) + assert count == 1 + + # Verify thread is deleted + thread.refresh_from_db() + assert thread.is_deleted is True + + # Verify comments are also soft-deleted + response.refresh_from_db() + reply.refresh_from_db() + assert response.is_deleted is True + assert reply.is_deleted is True + + +@pytest.mark.django_db +def test_delete_user_threads_stats_rebuild() -> None: + """Test that stats are rebuilt once per course after bulk thread delete.""" + user = User.objects.create(username="testuser") + + # Create threads in different courses + CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 1", + body="Body 1", + thread_type="discussion", + ) + CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread 2", + body="Body 2", + thread_type="discussion", + ) + CommentThread.objects.create( + author=user, + course_id="course2", + title="Thread 3", + body="Body 3", + thread_type="discussion", + ) + + # Mock build_course_stats to verify it's called efficiently + with patch.object(backend, "build_course_stats") as mock_build: + backend.delete_user_threads(str(user.pk), ["course1", "course2"]) + + # Should be called once per course, not once per thread + assert mock_build.call_count == 2 + mock_build.assert_any_call(str(user.pk), "course1") + mock_build.assert_any_call(str(user.pk), "course2") + + +@pytest.mark.django_db +def test_delete_user_comments_replies_first() -> None: + """Test that replies are deleted before responses to avoid redundant work.""" + user = User.objects.create(username="testuser") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create response and reply by user + response = Comment.objects.create( + author=user, + course_id="course1", + body="Response", + comment_thread=thread, + ) + reply = Comment.objects.create( + author=user, + course_id="course1", + body="Reply", + comment_thread=thread, + parent=response, + ) + + # Delete comments + count = backend.delete_user_comments(str(user.pk), ["course1"]) + + # Should count both the response and reply + assert count == 2 + + # Verify both are deleted + response.refresh_from_db() + reply.refresh_from_db() + assert response.is_deleted is True + assert reply.is_deleted is True + + +@pytest.mark.django_db +def test_delete_user_comments_parent_child() -> None: + """Test deleting parent comment also deletes its children.""" + user = User.objects.create(username="testuser") + other_user = User.objects.create(username="otheruser") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create response by user with child reply by other user + response = Comment.objects.create( + author=user, + course_id="course1", + body="User Response", + comment_thread=thread, + ) + other_reply = Comment.objects.create( + author=other_user, + course_id="course1", + body="Other's Reply", + comment_thread=thread, + parent=response, + ) + + # Delete user's comments + count = backend.delete_user_comments(str(user.pk), ["course1"]) + + # Should delete response and its child reply + assert count == 2 + + # Verify parent is deleted + response.refresh_from_db() + assert response.is_deleted is True + + # Verify child is also deleted (even though author is different) + other_reply.refresh_from_db() + assert other_reply.is_deleted is True + + +@pytest.mark.django_db +def test_delete_user_comments_stats_rebuild() -> None: + """Test that stats are rebuilt once per course after bulk comment delete.""" + user = User.objects.create(username="testuser") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create comments in different courses + Comment.objects.create( + author=user, + course_id="course1", + body="Comment 1", + comment_thread=thread, + ) + Comment.objects.create( + author=user, + course_id="course1", + body="Comment 2", + comment_thread=thread, + ) + Comment.objects.create( + author=user, + course_id="course2", + body="Comment 3", + comment_thread=thread, + ) + + # Mock build_course_stats to verify it's called efficiently + with patch.object(backend, "build_course_stats") as mock_build: + backend.delete_user_comments(str(user.pk), ["course1", "course2"]) + + # Should be called once per course, not once per comment + assert mock_build.call_count == 2 + mock_build.assert_any_call(str(user.pk), "course1") + mock_build.assert_any_call(str(user.pk), "course2") + + +@pytest.mark.django_db +def test_delete_user_comments_skips_already_deleted() -> None: + """Test that already deleted comments are not processed.""" + user = User.objects.create(username="testuser") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create active comment + active_comment = Comment.objects.create( + author=user, + course_id="course1", + body="Active Comment", + comment_thread=thread, + ) + # Create already deleted comment + deleted_comment = Comment.objects.create( + author=user, + course_id="course1", + body="Deleted Comment", + comment_thread=thread, + is_deleted=True, + ) + + # Delete comments + count = backend.delete_user_comments(str(user.pk), ["course1"]) + + # Should only count the active comment + assert count == 1 + + # Verify active comment is now deleted + active_comment.refresh_from_db() + assert active_comment.is_deleted is True + + # Verify already deleted comment is unchanged + deleted_comment.refresh_from_db() + assert deleted_comment.is_deleted is True + + +@pytest.mark.django_db +def test_delete_user_threads_with_deleted_by() -> None: + """Test that deleted_by is recorded when provided.""" + user = User.objects.create(username="testuser") + admin = User.objects.create(username="admin") + + # Create thread + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Delete with deleted_by + backend.delete_user_threads(str(user.pk), ["course1"], deleted_by=str(admin.pk)) + + # Verify deleted_by is set + thread.refresh_from_db() + assert thread.is_deleted is True + assert thread.deleted_by == admin + + +@pytest.mark.django_db +def test_delete_user_comments_with_deleted_by() -> None: + """Test that deleted_by is recorded for comments when provided.""" + user = User.objects.create(username="testuser") + admin = User.objects.create(username="admin") + + thread = CommentThread.objects.create( + author=user, + course_id="course1", + title="Thread", + body="Body", + thread_type="discussion", + ) + + # Create comment + comment = Comment.objects.create( + author=user, + course_id="course1", + body="Comment", + comment_thread=thread, + ) + + # Delete with deleted_by + backend.delete_user_comments(str(user.pk), ["course1"], deleted_by=str(admin.pk)) + + # Verify deleted_by is set + comment.refresh_from_db() + assert comment.is_deleted is True + assert comment.deleted_by == admin diff --git a/tests/test_backends/test_mysql/test_mysql_ban_models.py b/tests/test_backends/test_mysql/test_mysql_ban_models.py new file mode 100644 index 00000000..30aa573b --- /dev/null +++ b/tests/test_backends/test_mysql/test_mysql_ban_models.py @@ -0,0 +1,527 @@ +"""Tests for discussion ban models.""" + +# mypy: ignore-errors +# pylint: disable=redefined-outer-name,unused-argument + +import pytest +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from opaque_keys.edx.keys import CourseKey + +from forum.backends.mysql.models import ( # pylint: disable=import-error + DiscussionBan, + DiscussionBanException, +) + +User = get_user_model() + + +@pytest.fixture +def test_users(db): # db fixture ensures database access + """Create test users.""" + return { + "banned_user": User.objects.create( + username="banned_user", email="banned@example.com" + ), + "moderator": User.objects.create(username="moderator", email="mod@example.com"), + "another_user": User.objects.create( + username="another_user", email="another@example.com" + ), + } + + +@pytest.fixture +def test_course_keys(): + """Create test course keys.""" + return { + "harvard_cs50": CourseKey.from_string("course-v1:HarvardX+CS50+2024"), + "harvard_math": CourseKey.from_string("course-v1:HarvardX+Math101+2024"), + "mitx_python": CourseKey.from_string("course-v1:MITx+Python+2024"), + } + + +# ==================== DiscussionBan Model Tests ==================== + + +@pytest.mark.django_db +class TestDiscussionBanModel: + """Tests for DiscussionBan model.""" + + def test_create_course_level_ban(self, test_users, test_course_keys): + """Test creating a course-level ban.""" + ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Posting spam content", + is_active=True, + ) + + assert ban.user == test_users["banned_user"] + assert ban.course_id == test_course_keys["harvard_cs50"] + assert ban.scope == "course" + assert ban.banned_by == test_users["moderator"] + assert ban.reason == "Posting spam content" + assert ban.is_active is True + assert ban.org_key is None + assert ban.unbanned_at is None + assert ban.unbanned_by is None + + def test_create_org_level_ban(self, test_users): + """Test creating an organization-level ban.""" + ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Repeated violations across courses", + is_active=True, + ) + + assert ban.user == test_users["banned_user"] + assert ban.org_key == "HarvardX" + assert ban.scope == "organization" + assert ban.course_id is None + assert ban.is_active is True + + def test_unique_active_course_ban_constraint(self, test_users, test_course_keys): + """Test that duplicate active course-level bans are prevented.""" + # Create first ban + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="First ban", + is_active=True, + ) + + # Attempt to create duplicate - should fail + with pytest.raises(IntegrityError): + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Duplicate ban", + is_active=True, + ) + + def test_unique_active_org_ban_constraint(self, test_users): + """Test that duplicate active org-level bans are prevented.""" + # Create first ban + DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="First org ban", + is_active=True, + ) + + # Attempt to create duplicate - should fail + with pytest.raises(IntegrityError): + DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Duplicate org ban", + is_active=True, + ) + + def test_multiple_inactive_bans_allowed(self, test_users, test_course_keys): + """Test that multiple inactive bans are allowed (no unique constraint).""" + # Create first inactive ban + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="First ban - now inactive", + is_active=False, + ) + + # Create second inactive ban - should succeed + ban2 = DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Second ban - also inactive", + is_active=False, + ) + + assert ban2.is_active is False + assert ( + DiscussionBan.objects.filter( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + is_active=False, + ).count() + == 2 + ) + + def test_ban_str_representation_course_level(self, test_users, test_course_keys): + """Test string representation for course-level ban.""" + ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Test", + ) + + expected = f"Ban: {test_users['banned_user'].username} in {test_course_keys['harvard_cs50']} (course-level)" + assert str(ban) == expected + + def test_ban_str_representation_org_level(self, test_users): + """Test string representation for org-level ban.""" + ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Test", + ) + + expected = f"Ban: {test_users['banned_user'].username} in HarvardX (org-level)" + assert str(ban) == expected + + def test_clean_validation_course_scope_requires_course_id(self, test_users): + """Test that course-level bans require course_id.""" + ban = DiscussionBan( + user=test_users["banned_user"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Test", + # Missing course_id + ) + + with pytest.raises( + ValidationError, match="Course-level bans require course_id" + ): + ban.clean() + + def test_clean_validation_org_scope_requires_org_key(self, test_users): + """Test that org-level bans require org_key.""" + ban = DiscussionBan( + user=test_users["banned_user"], + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Test", + # Missing org_key + ) + + with pytest.raises( + ValidationError, match="Organization-level bans require organization" + ): + ban.clean() + + def test_clean_validation_org_scope_cannot_have_course_id( + self, test_users, test_course_keys + ): + """Test that org-level bans should not have course_id set.""" + ban = DiscussionBan( + user=test_users["banned_user"], + org_key="HarvardX", + course_id=test_course_keys["harvard_cs50"], # Should not be set + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Test", + ) + + with pytest.raises( + ValidationError, + match="Organization-level bans should not have course_id set", + ): + ban.clean() + + def test_is_user_banned_course_level(self, test_users, test_course_keys): + """Test is_user_banned for course-level ban.""" + # Create course-level ban + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Spam", + is_active=True, + ) + + # User should be banned in CS50 + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_cs50"] + ) + is True + ) + + # User should NOT be banned in Math101 + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_math"] + ) + is False + ) + + # Another user should NOT be banned + assert ( + DiscussionBan.is_user_banned( + test_users["another_user"], test_course_keys["harvard_cs50"] + ) + is False + ) + + def test_is_user_banned_org_level(self, test_users, test_course_keys): + """Test is_user_banned for org-level ban (applies to all courses in org).""" + # Create org-level ban for HarvardX + DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org-wide violation", + is_active=True, + ) + + # User should be banned in all HarvardX courses + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_cs50"] + ) + is True + ) + + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_math"] + ) + is True + ) + + # User should NOT be banned in MITx course + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["mitx_python"] + ) + is False + ) + + def test_is_user_banned_inactive_ban_ignored(self, test_users, test_course_keys): + """Test that inactive bans are ignored.""" + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Old ban", + is_active=False, # Inactive + ) + + # User should NOT be banned (ban is inactive) + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_cs50"] + ) + is False + ) + + def test_is_user_banned_with_course_id_as_string( + self, test_users, test_course_keys + ): + """Test is_user_banned accepts course_id as string.""" + DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Spam", + is_active=True, + ) + + # Pass course_id as string + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], str(test_course_keys["harvard_cs50"]) + ) + is True + ) + + +# ==================== DiscussionBanException Model Tests ==================== + + +@pytest.mark.django_db +class TestDiscussionBanExceptionModel: + """Tests for DiscussionBanException model.""" + + def test_create_ban_exception(self, test_users, test_course_keys): + """Test creating a ban exception.""" + # Create org-level ban + org_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org-wide ban", + is_active=True, + ) + + # Create exception for CS50 + exception = DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + reason="Appeal approved for CS50", + ) + + assert exception.ban == org_ban + assert exception.course_id == test_course_keys["harvard_cs50"] + assert exception.unbanned_by == test_users["moderator"] + assert exception.reason == "Appeal approved for CS50" + + def test_exception_str_representation(self, test_users, test_course_keys): + """Test string representation of exception.""" + org_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org ban", + ) + + exception = DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + ) + + expected = f"Exception: {test_users['banned_user'].username} allowed in {test_course_keys['harvard_cs50']}" + assert str(exception) == expected + + def test_unique_ban_exception_constraint(self, test_users, test_course_keys): + """Test that duplicate exceptions for same ban + course are prevented.""" + org_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org ban", + ) + + # Create first exception + DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + ) + + # Attempt duplicate - should fail + with pytest.raises(IntegrityError): + DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + ) + + def test_exception_only_for_org_bans_validation(self, test_users, test_course_keys): + """Test that exceptions can only be created for org-level bans.""" + # Create course-level ban + course_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + course_id=test_course_keys["harvard_cs50"], + scope=DiscussionBan.SCOPE_COURSE, + banned_by=test_users["moderator"], + reason="Course ban", + ) + + # Try to create exception for course-level ban + exception = DiscussionBanException( + ban=course_ban, + course_id=test_course_keys["harvard_math"], + unbanned_by=test_users["moderator"], + ) + + with pytest.raises( + ValidationError, + match="Exceptions can only be created for organization-level bans", + ): + exception.clean() + + def test_org_ban_with_exception_allows_user(self, test_users, test_course_keys): + """Test that exception to org ban allows user in specific course.""" + # Create org-level ban for HarvardX + org_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org ban", + is_active=True, + ) + + # User is banned in all HarvardX courses + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_cs50"] + ) + is True + ) + + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_math"] + ) + is True + ) + + # Create exception for CS50 + DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + reason="Appeal approved", + ) + + # User should now be allowed in CS50 + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_cs50"] + ) + is False + ) + + # But still banned in Math101 + assert ( + DiscussionBan.is_user_banned( + test_users["banned_user"], test_course_keys["harvard_math"] + ) + is True + ) + + def test_exception_cascade_delete_with_ban(self, test_users, test_course_keys): + """Test that exceptions are deleted when parent ban is deleted.""" + org_ban = DiscussionBan.objects.create( + user=test_users["banned_user"], + org_key="HarvardX", + scope=DiscussionBan.SCOPE_ORGANIZATION, + banned_by=test_users["moderator"], + reason="Org ban", + ) + + exception = DiscussionBanException.objects.create( + ban=org_ban, + course_id=test_course_keys["harvard_cs50"], + unbanned_by=test_users["moderator"], + ) + + exception_id = exception.id + + # Delete parent ban + org_ban.delete() + + # Exception should be deleted + assert not DiscussionBanException.objects.filter(id=exception_id).exists() diff --git a/tests/test_views/test_bans.py b/tests/test_views/test_bans.py new file mode 100644 index 00000000..d773cf83 --- /dev/null +++ b/tests/test_views/test_bans.py @@ -0,0 +1,414 @@ +""" +Tests for discussion ban and unban API endpoints. +""" + +# mypy: ignore-errors +# pylint: disable=redefined-outer-name + +from urllib.parse import quote_plus + +import pytest +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey + +from forum.backends.mysql.models import DiscussionBan # pylint: disable=import-error +from test_utils.client import APIClient # pylint: disable=import-error + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def test_users(): + """Create test users for ban/unban tests.""" + learner = User.objects.create_user( + username="test_learner", email="learner@test.com", password="password" + ) + moderator = User.objects.create_user( + username="test_moderator", + email="moderator@test.com", + password="password", + is_staff=True, + ) + return {"learner": learner, "moderator": moderator} + + +def test_ban_user_course_level(api_client: APIClient, test_users: dict) -> None: + """Test banning a user at course level.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + data = { + "user_id": learner.id, + "banned_by_id": moderator.id, + "scope": "course", + "course_id": course_id, + "reason": "Posting spam content", + } + + response = api_client.post_json("/api/v2/users/bans", data=data) + + assert response.status_code == 201 + assert response.json()["user"]["id"] == learner.id + assert response.json()["scope"] == "course" + assert response.json()["course_id"] == course_id + assert response.json()["is_active"] is True + + # Verify ban was created in database + ban = DiscussionBan.objects.get(user=learner) + assert ban.scope == "course" + assert ban.is_active is True + + +def test_ban_user_org_level(api_client: APIClient, test_users: dict) -> None: + """Test banning a user at organization level.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + org_key = "edX" + + data = { + "user_id": learner.id, + "banned_by_id": moderator.id, + "scope": "organization", + "org_key": org_key, + "reason": "Repeated violations across courses", + } + + response = api_client.post_json("/api/v2/users/bans", data=data) + + assert response.status_code == 201 + assert response.json()["user"]["id"] == learner.id + assert response.json()["scope"] == "organization" + assert response.json()["org_key"] == org_key + assert response.json()["course_id"] is None + + +def test_ban_user_missing_course_id(api_client: APIClient, test_users: dict) -> None: + """Test banning fails when course_id is missing for course scope.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + + data = { + "user_id": learner.id, + "banned_by_id": moderator.id, + "scope": "course", + # Missing course_id + "reason": "Test reason", + } + + response = api_client.post_json("/api/v2/users/bans", data=data) + + assert response.status_code == 400 + assert "course_id" in str(response.json()) + + +def test_ban_user_invalid_user_id(api_client: APIClient, test_users: dict) -> None: + """Test banning fails with non-existent user ID.""" + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + data = { + "user_id": 99999, # Non-existent user + "banned_by_id": moderator.id, + "scope": "course", + "course_id": course_id, + "reason": "Test reason", + } + + response = api_client.post_json("/api/v2/users/bans", data=data) + + assert response.status_code == 404 + assert "not found" in str(response.json()).lower() + + +def test_ban_reactivates_previous_ban(api_client: APIClient, test_users: dict) -> None: + """Test that banning a previously unbanned user reactivates the ban.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create an inactive ban + ban = DiscussionBan.objects.create( + user=learner, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=False, + reason="Old ban", + ) + + data = { + "user_id": learner.id, + "banned_by_id": moderator.id, + "scope": "course", + "course_id": course_id, + "reason": "New ban reason", + } + + response = api_client.post_json("/api/v2/users/bans", data=data) + + assert response.status_code == 201 + + # Verify ban was reactivated + ban.refresh_from_db() + assert ban.is_active is True + assert ban.reason == "New ban reason" + + +def test_unban_course_level_ban(api_client: APIClient, test_users: dict) -> None: + """Test unbanning a user from a course-level ban.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create active course-level ban + ban = DiscussionBan.objects.create( + user=learner, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Spam posting", + ) + + data = {"unbanned_by_id": moderator.id, "reason": "Appeal approved"} + + response = api_client.post_json(f"/api/v2/users/bans/{ban.id}/unban", data=data) + + assert response.status_code == 200 + assert response.json()["status"] == "success" + assert response.json()["exception_created"] is False + + # Verify ban was deactivated + ban.refresh_from_db() + assert ban.is_active is False + assert ban.unbanned_at is not None + + +def test_unban_org_level_ban_completely( + api_client: APIClient, test_users: dict +) -> None: + """Test completely unbanning a user from organization-level ban.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + + # Create active org-level ban + ban = DiscussionBan.objects.create( + user=learner, + org_key="edX", + scope="organization", + banned_by=moderator, + is_active=True, + reason="Repeated violations", + ) + + data = {"unbanned_by_id": moderator.id, "reason": "Ban period expired"} + + response = api_client.post_json(f"/api/v2/users/bans/{ban.id}/unban", data=data) + + assert response.status_code == 200 + assert response.json()["status"] == "success" + assert response.json()["exception_created"] is False + + # Verify ban was deactivated + ban.refresh_from_db() + assert ban.is_active is False + + +def test_unban_org_ban_with_course_exception( + api_client: APIClient, test_users: dict +) -> None: + """Test creating a course exception to an organization-level ban.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create active org-level ban + ban = DiscussionBan.objects.create( + user=learner, + org_key="edX", + scope="organization", + banned_by=moderator, + is_active=True, + reason="Repeated violations", + ) + + data = { + "unbanned_by_id": moderator.id, + "course_id": course_id, + "reason": "Approved for this course", + } + + response = api_client.post_json(f"/api/v2/users/bans/{ban.id}/unban", data=data) + + assert response.status_code == 200 + assert response.json()["status"] == "success" + assert response.json()["exception_created"] is True + assert response.json()["exception"] is not None + + # Verify ban is still active + ban.refresh_from_db() + assert ban.is_active is True + + # Verify exception was created + assert response.json()["exception"]["course_id"] == course_id + + +def test_unban_invalid_ban_id(api_client: APIClient, test_users: dict) -> None: + """Test unbanning fails with invalid ban ID.""" + moderator = test_users["moderator"] + + data = {"unbanned_by_id": moderator.id, "reason": "Test"} + + response = api_client.post_json("/api/v2/users/bans/99999/unban", data=data) + + assert response.status_code == 404 + assert "not found" in str(response.json()).lower() + + +def test_list_all_active_bans(api_client: APIClient, test_users: dict) -> None: + """Test listing all active bans.""" + learner1 = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create another user + learner2 = User.objects.create_user( + username="learner2", email="learner2@test.com", password="password" + ) + + # Create bans + _ban1 = DiscussionBan.objects.create( + user=learner1, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Spam", + ) + _ban2 = DiscussionBan.objects.create( + user=learner2, + org_key="edX", + scope="organization", + banned_by=moderator, + is_active=True, + reason="Violations", + ) + + response = api_client.get("/api/v2/users/banned") + + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_list_bans_filtered_by_course(api_client: APIClient, test_users: dict) -> None: + """Test listing bans filtered by course ID.""" + learner1 = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create another user + learner2 = User.objects.create_user( + username="learner2", email="learner2@test.com", password="password" + ) + + # Create bans in different courses + _ban1 = DiscussionBan.objects.create( + user=learner1, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Spam", + ) + _ban2 = DiscussionBan.objects.create( + user=learner2, + course_id=CourseKey.from_string("course-v1:edX+Other+Course"), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Violations", + ) + + response = api_client.get(f"/api/v2/users/banned?course_id={quote_plus(course_id)}") + + assert response.status_code == 200 + # Should return ban1 and any org-level bans for this org + assert len(response.json()) >= 1 + assert response.json()[0]["user"]["id"] == learner1.id + + +def test_list_bans_include_inactive(api_client: APIClient, test_users: dict) -> None: + """Test listing bans including inactive ones.""" + learner1 = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + # Create another user + learner2 = User.objects.create_user( + username="learner2", email="learner2@test.com", password="password" + ) + + # Create active and inactive bans + _ban1 = DiscussionBan.objects.create( + user=learner1, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Spam", + ) + _ban2 = DiscussionBan.objects.create( + user=learner2, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=False, + reason="Old ban", + ) + + response = api_client.get("/api/v2/users/banned?include_inactive=true") + + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_get_ban_details_success(api_client: APIClient, test_users: dict) -> None: + """Test retrieving ban details successfully.""" + learner = test_users["learner"] + moderator = test_users["moderator"] + course_id = "course-v1:edX+DemoX+Demo_Course" + + ban = DiscussionBan.objects.create( + user=learner, + course_id=CourseKey.from_string(course_id), + org_key="edX", + scope="course", + banned_by=moderator, + is_active=True, + reason="Spam posting", + ) + + response = api_client.get(f"/api/v2/users/bans/{ban.id}") + + assert response.status_code == 200 + assert response.json()["id"] == ban.id + assert response.json()["user"]["id"] == learner.id + assert response.json()["scope"] == "course" + assert response.json()["is_active"] is True + + +def test_get_ban_details_not_found(api_client: APIClient) -> None: + """Test retrieving non-existent ban returns 404.""" + response = api_client.get("/api/v2/users/bans/99999") + + assert response.status_code == 404 + assert "not found" in str(response.json()).lower() diff --git a/tests/test_views/test_comments.py b/tests/test_views/test_comments.py index 799a6145..ff0b89ae 100644 --- a/tests/test_views/test_comments.py +++ b/tests/test_views/test_comments.py @@ -1,9 +1,10 @@ """Test comments api endpoints.""" from typing import Any + import pytest -from test_utils.client import APIClient +from test_utils.client import APIClient # pylint: disable=import-error pytestmark = pytest.mark.django_db @@ -121,7 +122,9 @@ def test_update_comment_endorsed_api( def test_delete_parent_comment(api_client: APIClient, patched_get_backend: Any) -> None: """ - Test deleting a comment. + Test soft-deleting a parent comment. + + Note: Soft delete marks the comment as deleted (is_deleted=True) but doesn't remove it. """ backend = patched_get_backend user_id, _, parent_comment_id = setup_models(backend) @@ -137,12 +140,16 @@ def test_delete_parent_comment(api_client: APIClient, patched_get_backend: Any) assert response.status_code == 200 response = api_client.delete_json(f"/api/v2/comments/{parent_comment_id}") assert response.status_code == 200 - assert backend.get_comment(parent_comment_id) is None + deleted_comment = backend.get_comment(parent_comment_id) + assert deleted_comment is None or deleted_comment.get("is_deleted") is True def test_delete_child_comment(api_client: APIClient, patched_get_backend: Any) -> None: """ - Test creating a new child comment. + Test soft-deleting a child comment. + + Note: Soft delete marks the comment as deleted but does NOT decrement + the parent's child_count (this matches the MongoDB behavior). """ backend = patched_get_backend user_id, _, parent_comment_id = setup_models(backend) @@ -165,13 +172,14 @@ def test_delete_child_comment(api_client: APIClient, patched_get_backend: Any) - response = api_client.delete_json(f"/api/v2/comments/{child_comment_id}") assert previous_child_count is not None assert response.status_code == 200 - assert backend.get_comment(child_comment_id) is None + deleted_child = backend.get_comment(child_comment_id) + assert deleted_child is None or deleted_child.get("is_deleted") is True parent_comment = backend.get_comment(parent_comment_id) or {} new_child_count = parent_comment.get("child_count") assert new_child_count is not None - assert new_child_count == previous_child_count - 1 + assert new_child_count == previous_child_count def test_returns_400_when_comment_does_not_exist( diff --git a/tests/test_views/test_threads.py b/tests/test_views/test_threads.py index ca28d864..da858c85 100644 --- a/tests/test_views/test_threads.py +++ b/tests/test_views/test_threads.py @@ -224,9 +224,12 @@ def test_delete_thread(api_client: APIClient, patched_get_backend: Any) -> None: assert thread_from_db["comment_count"] == 2 response = api_client.delete_json(f"/api/v2/threads/{thread_id}") assert response.status_code == 200 - assert backend.get_thread(thread_id) is None - assert backend.get_comment(comment_id_1) is None - assert backend.get_comment(comment_id_2) is None + thread = backend.get_thread(thread_id) + comment_1 = backend.get_comment(comment_id_1) + comment_2 = backend.get_comment(comment_id_2) + assert thread is None or thread.get("is_deleted", False) is True + assert comment_1 is None or comment_1.get("is_deleted", False) is True + assert comment_2 is None or comment_2.get("is_deleted", False) is True assert backend.get_subscription(subscriber_id=user_id, source_id=thread_id) is None @@ -854,46 +857,6 @@ def test_response_for_thread_type_question( assert thread["non_endorsed_resp_total"] == 1 -def test_read_states_deletion_of_a_thread_on_thread_deletion( - api_client: APIClient, patched_mongo_backend: MongoBackend -) -> None: - """Test delete read_states of the thread on deletion of a thread for mongodb.""" - user_id, thread_id = setup_models(backend=patched_mongo_backend) - comment_id_1, comment_id_2 = create_comments_in_a_thread( - patched_mongo_backend, thread_id - ) - thread_from_db = patched_mongo_backend.get_thread(thread_id) - assert thread_from_db is not None - assert thread_from_db["comment_count"] == 2 - get_thread_response = api_client.get_json( - f"/api/v2/threads/{thread_id}", - params={ - "recursive": False, - "with_responses": True, - "user_id": int(user_id), - "mark_as_read": False, - "resp_skip": 0, - "resp_limit": 10, - "reverse_order": "true", - "merge_question_type_responses": False, - }, - ) # call get_thread API to save read_states of this thread in user model - assert get_thread_response.status_code == 200 - assert is_thread_id_exists_in_user_read_state(user_id, thread_id) is True - response = api_client.delete_json(f"/api/v2/threads/{thread_id}") - assert response.status_code == 200 - assert patched_mongo_backend.get_thread(thread_id) is None - assert patched_mongo_backend.get_comment(comment_id_1) is None - assert patched_mongo_backend.get_comment(comment_id_2) is None - assert ( - patched_mongo_backend.get_subscription( - subscriber_id=user_id, source_id=thread_id - ) - is None - ) - assert is_thread_id_exists_in_user_read_state(user_id, thread_id) is False - - def is_thread_id_exists_in_user_read_state(user_id: str, thread_id: str) -> bool: """Return True or False if thread_id exists in read_states of any user.""" user = Users().find_one({"_id": user_id}) @@ -1052,9 +1015,12 @@ def test_read_states_deletion_on_thread_deletion_without_read_states( response = api_client.delete_json(f"/api/v2/threads/{thread_id}") assert response.status_code == 200 - assert patched_mongo_backend.get_thread(thread_id) is None - assert patched_mongo_backend.get_comment(comment_id_1) is None - assert patched_mongo_backend.get_comment(comment_id_2) is None + thread = patched_mongo_backend.get_thread(thread_id) + comment_1 = patched_mongo_backend.get_comment(comment_id_1) + comment_2 = patched_mongo_backend.get_comment(comment_id_2) + assert thread is None or thread.get("is_deleted", False) is True + assert comment_1 is None or comment_1.get("is_deleted", False) is True + assert comment_2 is None or comment_2.get("is_deleted", False) is True assert ( patched_mongo_backend.get_subscription( subscriber_id=user_id, source_id=thread_id @@ -1064,59 +1030,6 @@ def test_read_states_deletion_on_thread_deletion_without_read_states( assert is_thread_id_exists_in_user_read_state(user_id, thread_id) is False -def test_read_states_deletion_on_thread_deletion_with_multiple_read_states( - api_client: APIClient, patched_mongo_backend: MongoBackend -) -> None: - """Test delete read_states of the thread on deletion of a thread when there are multiple read states.""" - # Setup first thread and read state - user_id_1, thread_id_1 = setup_models(backend=patched_mongo_backend) - get_thread_response = api_client.get_json( - f"/api/v2/threads/{thread_id_1}", - params={ - "recursive": False, - "with_responses": True, - "user_id": int(user_id_1), - "mark_as_read": True, - "resp_skip": 0, - "resp_limit": 10, - "reverse_order": "true", - "merge_question_type_responses": False, - }, - ) - assert get_thread_response.status_code == 200 - assert is_thread_id_exists_in_user_read_state(user_id_1, thread_id_1) is True - - # Setup second thread and read state - user_id_2, thread_id_2 = setup_models( - backend=patched_mongo_backend, - user_id="2", - username="user2", - course_id="course2", - ) - get_thread_response = api_client.get_json( - f"/api/v2/threads/{thread_id_2}", - params={ - "recursive": False, - "with_responses": True, - "user_id": int(user_id_2), - "mark_as_read": True, - "resp_skip": 0, - "resp_limit": 10, - "reverse_order": "true", - "merge_question_type_responses": False, - }, - ) - assert get_thread_response.status_code == 200 - assert is_thread_id_exists_in_user_read_state(user_id_2, thread_id_2) is True - - # Delete first thread and verify its read state is removed while second remains - response = api_client.delete_json(f"/api/v2/threads/{thread_id_1}") - assert response.status_code == 200 - assert patched_mongo_backend.get_thread(thread_id_1) is None - assert is_thread_id_exists_in_user_read_state(user_id_1, thread_id_1) is False - assert is_thread_id_exists_in_user_read_state(user_id_2, thread_id_2) is True - - def test_read_states_deletion_checks_thread_id_existence( api_client: APIClient, patched_mongo_backend: MongoBackend ) -> None: