From e19479bfca266d52b9838e39dc9f863764496b08 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Wed, 5 Nov 2025 19:16:47 +0530 Subject: [PATCH 01/22] feat: AI moderation system (#1) This adds feature of AI moderation system enabled with discussions.enable_ai_moderation waffle flag. This is MVP so various changes on top of this are expected. --- forum/__init__.py | 2 +- forum/admin.py | 104 +++++- forum/ai_moderation.py | 319 ++++++++++++++++++ forum/api/comments.py | 18 +- forum/api/threads.py | 12 +- forum/backends/mongodb/api.py | 45 +++ forum/backends/mongodb/comments.py | 6 +- forum/backends/mongodb/threads.py | 6 +- forum/backends/mysql/api.py | 47 +++ forum/backends/mysql/models.py | 105 ++++++ ...rationauditlog_comment_is_spam_and_more.py | 191 +++++++++++ forum/serializers/contents.py | 2 + forum/toggles.py | 19 ++ 13 files changed, 868 insertions(+), 8 deletions(-) create mode 100644 forum/ai_moderation.py create mode 100644 forum/migrations/0005_moderationauditlog_comment_is_spam_and_more.py diff --git a/forum/__init__.py b/forum/__init__.py index 3a5afaa3..44c360c3 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.3.8" +__version__ = "0.3.9" diff --git a/forum/admin.py b/forum/admin.py index 3d624a8e..c1ff0239 100644 --- a/forum/admin.py +++ b/forum/admin.py @@ -14,6 +14,7 @@ UserVote, Subscription, MongoContent, + ModerationAuditLog, ) @@ -55,11 +56,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 +76,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) @@ -152,3 +155,100 @@ class MongoContentAdmin(admin.ModelAdmin): # type: ignore 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..5e9233e8 --- /dev/null +++ b/forum/ai_moderation.py @@ -0,0 +1,319 @@ +""" +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.utils import timezone +from opaque_keys.edx.keys import CourseKey + +from forum.backends.mysql.models import ModerationAuditLog + +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, + ) + + 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: + try: + content_instance["is_spam"] = True + + self._mark_as_spam_and_flag_abuse(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"] + 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_flag_abuse(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" + ) + } + try: + 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 + ) + except (AttributeError, ValueError, TypeError, ImportError) as e: + log.error(f"Failed to flag content via backend: {e}") + + +# 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/comments.py b/forum/api/comments.py index edc14a1c..38a9a8bd 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"], ) @@ -291,6 +300,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) diff --git a/forum/api/threads.py b/forum/api/threads.py index b5b036c5..5eb60768 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 @@ -327,15 +328,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, diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index b279ac8e..cddc412d 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1044,6 +1044,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"): @@ -1586,6 +1587,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 @@ -1763,3 +1765,46 @@ 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) diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index a50563c2..7f9af685 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -78,6 +78,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 +95,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 +105,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 +165,7 @@ def update( edit_reason_code: Optional[str] = None, endorsement_user_id: Optional[str] = None, sk: Optional[str] = None, + is_spam: Optional[bool] = None, ) -> int: """ Updates a comment document in the database. @@ -206,6 +209,7 @@ def update( ("depth", depth), ("closed", closed), ("sk", sk), + ("is_spam", is_spam), ] update_data: dict[str, Any] = { field: value for field, value in fields if value is not None diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index be8e9638..61126624 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -100,6 +100,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 +120,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 +163,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 +207,7 @@ def update( closed_by_id: Optional[str] = None, group_id: Optional[int] = None, skip_timestamp_update: bool = False, + is_spam: Optional[bool] = None, ) -> int: """ Updates a thread document in the database. @@ -258,6 +261,7 @@ def update( ("close_reason_code", close_reason_code), ("closed_by_id", closed_by_id), ("group_id", group_id), + ("is_spam", is_spam), ] update_data: dict[str, Any] = { field: value for field, value in fields if value is not None diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 93591ac8..c8633476 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1686,6 +1686,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 @@ -1898,6 +1901,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 @@ -2208,3 +2214,44 @@ 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) diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index 3924a0d6..e149daa6 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -125,6 +125,10 @@ 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", + ) uservote = GenericRelation( "UserVote", object_id_field="content_object_id", @@ -318,6 +322,7 @@ 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, } def doc_to_hash(self) -> dict[str, Any]: @@ -353,6 +358,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 +508,7 @@ 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, } if edit_history: data["edit_history"] = edit_history @@ -538,6 +547,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 +786,96 @@ class MongoContent(models.Model): class Meta: app_label = "forum" + + +class ModerationAuditLog(models.Model): + """Audit log for AI moderation decisions on spam content.""" + + # Available actions that can be taken on spam content + ACTION_CHOICES = [ + ("flagged", "Content Flagged"), + ("soft_deleted", "Content Soft Deleted"), + ("no_action", "No Action Taken"), + ] + + # Only spam classifications since we don't store non-spam entries + CLASSIFICATION_CHOICES = [ + ("spam", "Spam"), + ("spam_or_scam", "Spam or Scam"), + ] + + timestamp: models.DateTimeField[datetime, datetime] = models.DateTimeField( + default=timezone.now, help_text="When the moderation decision was made" + ) + body: models.TextField[str, str] = models.TextField( + help_text="The content body that was moderated" + ) + classifier_output: models.JSONField[dict[str, Any], dict[str, Any]] = ( + models.JSONField(help_text="Full output from the AI classifier") + ) + reasoning: models.TextField[str, str] = models.TextField( + help_text="AI reasoning for the decision" + ) + classification: models.CharField[str, str] = models.CharField( + max_length=20, + choices=CLASSIFICATION_CHOICES, + help_text="AI classification result", + ) + actions_taken: models.JSONField[list[str], list[str]] = models.JSONField( + default=list, + help_text="List of actions taken based on moderation (e.g., ['flagged', 'soft_deleted'])", + ) + confidence_score: models.FloatField[Optional[float], float] = models.FloatField( + null=True, blank=True, help_text="AI confidence score if available" + ) + 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( + blank=True, null=True, help_text="Reason for moderator override" + ) + moderator: models.ForeignKey[User, User] = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="moderation_actions", + help_text="Human moderator who made override", + ) + original_author: models.ForeignKey[User, User] = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="moderated_content", + help_text="Original author of the moderated content", + ) + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the model.""" + return { + "_id": str(self.pk), + "timestamp": self.timestamp.isoformat(), + "body": self.body, + "classifier_output": self.classifier_output, + "reasoning": self.reasoning, + "classification": self.classification, + "actions_taken": self.actions_taken, + "confidence_score": self.confidence_score, + "moderator_override": self.moderator_override, + "override_reason": self.override_reason, + "moderator_id": str(self.moderator.pk) if self.moderator else None, + "moderator_username": self.moderator.username if self.moderator else None, + "original_author_id": str(self.original_author.pk), + "original_author_username": self.original_author.username, + } + + 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=["classification"]), + models.Index(fields=["original_author"]), + models.Index(fields=["moderator"]), + ] 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/serializers/contents.py b/forum/serializers/contents.py index d01f69eb..6fd174b7 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,7 @@ 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) def create(self, validated_data: dict[str, Any]) -> Any: """Raise NotImplementedError""" diff --git a/forum/toggles.py b/forum/toggles.py index 62616cc4..013810f6 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,21 @@ 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__ +) + + +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) From 1eb7b285eee9e068fb83b3fed0032327dcb154b5 Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Tue, 18 Nov 2025 12:42:06 +0530 Subject: [PATCH 02/22] fix: team posts now visible by handling standalone context properly (#5) https://2u-internal.atlassian.net/jira/software/c/projects/COSMO2/boards/2821?selectedIssue=COSMO2-776 --- forum/api/threads.py | 1 + forum/backends/mongodb/api.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/forum/api/threads.py b/forum/api/threads.py index 5eb60768..4f14139a 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -393,6 +393,7 @@ def get_user_threads( "user_id": user_id, "group_id": group_id, "group_ids": group_ids, + "context": kwargs.get("context"), } params = {k: v for k, v in params.items() if v is not None} backend.validate_params(params) diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index cddc412d..609a9a0e 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -946,6 +946,7 @@ def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> No "per_page", "request_id", "commentable_ids", + "context", "group_id", "group_ids", ] @@ -990,6 +991,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), ) From 8f6699d8ac4ff913c65dc07ed64fb38843138e35 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Tue, 6 Jan 2026 18:56:20 +0530 Subject: [PATCH 03/22] feat: soft delete feature (#4) Implements soft delete functionality for discussion threads, responses, and comments using the is_deleted flag instead of permanently deleting records. This enables safe deletion and restoration of discussion content while preserving existing data. --- forum/__init__.py | 2 +- forum/api/__init__.py | 13 +- forum/api/comments.py | 96 +++++- forum/api/search.py | 2 + forum/api/threads.py | 83 ++++- forum/api/users.py | 2 + forum/backends/backend.py | 24 ++ forum/backends/mongodb/api.py | 249 +++++++++++++- forum/backends/mongodb/comments.py | 227 ++++++++++++- forum/backends/mongodb/threads.py | 119 +++++++ forum/backends/mysql/api.py | 316 +++++++++++++++++- forum/backends/mysql/models.py | 35 +- ..._deleted_at_comment_deleted_by_and_more.py | 83 +++++ forum/serializers/contents.py | 3 + forum/views/comments.py | 4 +- tests/e2e/test_users.py | 10 +- .../test_mongodb/test_comments.py | 4 +- tests/test_views/test_comments.py | 20 +- tests/test_views/test_threads.py | 30 +- 19 files changed, 1239 insertions(+), 83 deletions(-) create mode 100644 forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py diff --git a/forum/__init__.py b/forum/__init__.py index 44c360c3..bc1fa6c4 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.3.9" +__version__ = "0.4.0" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..5a043360 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -8,14 +8,14 @@ create_parent_comment, delete_comment, get_course_id_by_comment, + get_deleted_comments_for_course, get_parent_comment, 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 ( @@ -28,8 +28,11 @@ create_thread, delete_thread, get_course_id_by_thread, + get_deleted_threads_for_course, get_thread, get_user_threads, + restore_thread, + restore_user_deleted_threads, update_thread, ) from .users import ( @@ -73,6 +76,8 @@ "get_user_course_stats", "get_user_subscriptions", "get_user_threads", + "get_deleted_comments_for_course", + "get_deleted_threads_for_course", "mark_thread_as_read", "pin_thread", "retire_user", diff --git a/forum/api/comments.py b/forum/api/comments.py index 38a9a8bd..7d0b198d 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -220,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: @@ -244,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 @@ -388,3 +411,64 @@ 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 + ) 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 4f14139a..e5795553 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -159,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. """ @@ -177,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: @@ -187,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 @@ -393,6 +406,7 @@ 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} @@ -420,3 +434,64 @@ 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 + ) diff --git a/forum/api/users.py b/forum/api/users.py index 71c3a36e..19a47fb5 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -198,6 +198,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 +252,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) diff --git a/forum/backends/backend.py b/forum/backends/backend.py index 8a5b9175..c281ace2 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -476,3 +476,27 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: Retrieve all threads and comments authored by a specific user. """ 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 609a9a0e..e3b3dcf0 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1,20 +1,20 @@ +# pylint: disable=cyclic-import """Model util function for db operations.""" import math from datetime import datetime, timezone from typing import Any, Optional -from bson import ObjectId, errors as bson_errors +from bson import ObjectId +from bson import errors as bson_errors from django.core.exceptions import ObjectDoesNotExist from forum.backends.backend import AbstractBackend -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.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -39,13 +39,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, @@ -555,6 +551,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 +575,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 +596,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 +911,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.""" @@ -949,6 +973,7 @@ def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> No "context", "group_id", "group_ids", + "is_deleted", ] if not user_id: valid_params.append("user_id") @@ -1366,6 +1391,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": "", } @@ -1459,10 +1487,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 @@ -1504,8 +1573,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 @@ -1538,6 +1605,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.""" @@ -1571,6 +1649,42 @@ 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.""" + Users().delete_read_state_by_thread_id(thread_id) + 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 create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1701,6 +1815,19 @@ 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] + }, + } + }, {"$sort": sort_criterion}, { "$facet": { @@ -1726,7 +1853,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]: diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 7f9af685..33093954 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( @@ -166,6 +169,9 @@ def update( 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. @@ -210,6 +216,9 @@ def update( ("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 @@ -252,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( @@ -287,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: """ @@ -367,3 +420,147 @@ 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 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 61126624..1b04b081 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( @@ -208,6 +211,9 @@ def update( 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. @@ -262,6 +268,9 @@ def update( ("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 @@ -301,3 +310,113 @@ 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 diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index c8633476..b261fd27 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -10,8 +10,8 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db.models import ( - Count, Case, + Count, Exists, F, IntegerField, @@ -19,8 +19,8 @@ OuterRef, Q, Subquery, - When, Sum, + When, ) from django.utils import timezone from rest_framework import status @@ -62,6 +62,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): @@ -608,6 +611,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 +657,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 +990,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.""" @@ -1373,10 +1405,18 @@ def build_course_stats(cls, author_id: str, course_id: str) -> None: comments_updated_at or timezone.now() - 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 +1490,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 +1572,179 @@ 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 + @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.""" @@ -1771,6 +1986,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.""" @@ -1911,14 +2140,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] @@ -2158,8 +2392,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: @@ -2255,3 +2495,57 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: return cls.update_thread(content_id, **update_data) else: return cls.update_comment(content_id, **update_data) + + @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 e149daa6..4ebf76eb 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -63,6 +63,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 +82,9 @@ 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, "course_id": self.course_id, "last_activity_at": self.last_activity_at, } @@ -129,6 +135,25 @@ class Content(models.Model): 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", @@ -267,8 +292,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: @@ -323,6 +348,9 @@ def to_dict(self) -> dict[str, Any]: "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]: @@ -509,6 +537,9 @@ def to_dict(self) -> dict[str, Any]: "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 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/serializers/contents.py b/forum/serializers/contents.py index 6fd174b7..bb4dcbd7 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -78,6 +78,9 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): 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/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/tests/e2e/test_users.py b/tests/e2e/test_users.py index de1119e2..893240cf 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( 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_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..092596ae 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 @@ -882,9 +885,12 @@ def test_read_states_deletion_of_a_thread_on_thread_deletion( 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 + 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 @@ -1052,9 +1058,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 @@ -1112,7 +1121,8 @@ def test_read_states_deletion_on_thread_deletion_with_multiple_read_states( # 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 + thread = patched_mongo_backend.get_thread(thread_id_1) + assert thread is None or thread.get("is_deleted", False) is True 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 From 41179a160201bcabf0f25e265dc772868d80c01c Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Wed, 7 Jan 2026 21:37:27 +0530 Subject: [PATCH 04/22] Revert "feat: soft delete feature (#4)" (#9) This reverts commit 8f6699d8ac4ff913c65dc07ed64fb38843138e35. --- forum/__init__.py | 2 +- forum/api/__init__.py | 13 +- forum/api/comments.py | 96 +----- forum/api/search.py | 2 - forum/api/threads.py | 83 +---- forum/api/users.py | 2 - forum/backends/backend.py | 24 -- forum/backends/mongodb/api.py | 249 +------------- forum/backends/mongodb/comments.py | 227 +------------ forum/backends/mongodb/threads.py | 119 ------- forum/backends/mysql/api.py | 316 +----------------- forum/backends/mysql/models.py | 35 +- ..._deleted_at_comment_deleted_by_and_more.py | 83 ----- forum/serializers/contents.py | 3 - forum/views/comments.py | 4 +- tests/e2e/test_users.py | 10 +- .../test_mongodb/test_comments.py | 4 +- tests/test_views/test_comments.py | 20 +- tests/test_views/test_threads.py | 30 +- 19 files changed, 83 insertions(+), 1239 deletions(-) delete mode 100644 forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py diff --git a/forum/__init__.py b/forum/__init__.py index bc1fa6c4..44c360c3 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.0" +__version__ = "0.3.9" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 5a043360..93c0dad7 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -8,14 +8,14 @@ create_parent_comment, delete_comment, get_course_id_by_comment, - get_deleted_comments_for_course, get_parent_comment, 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 ( @@ -28,11 +28,8 @@ create_thread, delete_thread, get_course_id_by_thread, - get_deleted_threads_for_course, get_thread, get_user_threads, - restore_thread, - restore_user_deleted_threads, update_thread, ) from .users import ( @@ -76,8 +73,6 @@ "get_user_course_stats", "get_user_subscriptions", "get_user_threads", - "get_deleted_comments_for_course", - "get_deleted_threads_for_course", "mark_thread_as_read", "pin_thread", "retire_user", diff --git a/forum/api/comments.py b/forum/api/comments.py index 7d0b198d..38a9a8bd 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -220,16 +220,12 @@ def update_comment( raise error -def delete_comment( - comment_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None -) -> dict[str, Any]: +def delete_comment(comment_id: str, course_id: 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: @@ -248,33 +244,14 @@ def delete_comment( backend, exclude_fields=["endorsement", "sk"], ) + backend.delete_comment(comment_id) author_id = comment["author_id"] comment_course_id = comment["course_id"] - - # 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, - ) + parent_comment_id = data["parent_id"] + if parent_comment_id: + backend.update_stats_for_course(author_id, comment_course_id, replies=-1) else: - # Only a reply was deleted (no response) - backend.update_stats_for_course( - author_id, - comment_course_id, - replies=-replies_deleted, - deleted_replies=replies_deleted, - ) + backend.update_stats_for_course(author_id, comment_course_id, responses=-1) return data @@ -411,64 +388,3 @@ 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 - ) diff --git a/forum/api/search.py b/forum/api/search.py index 60c5ea00..bec053d4 100644 --- a/forum/api/search.py +++ b/forum/api/search.py @@ -75,7 +75,6 @@ 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. @@ -108,7 +107,6 @@ 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 e5795553..4f14139a 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -159,16 +159,12 @@ def get_thread( raise ForumV2RequestError("Failed to prepare thread API response") from error -def delete_thread( - thread_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None -) -> dict[str, Any]: +def delete_thread(thread_id: str, course_id: 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. """ @@ -181,9 +177,7 @@ def delete_thread( f"Thread does not exist with Id: {thread_id}" ) from exc - count_of_response_deleted, count_of_replies_deleted = ( - backend.soft_delete_comments_of_a_thread(thread_id, deleted_by) - ) + backend.delete_comments_of_a_thread(thread_id) thread = backend.validate_object("CommentThread", thread_id) try: @@ -193,17 +187,10 @@ def delete_thread( raise ForumV2RequestError("Failed to prepare thread API response") from error backend.delete_subscriptions_of_a_thread(thread_id) - result = backend.soft_delete_thread(thread_id, deleted_by) + result = backend.delete_thread(thread_id) if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): backend.update_stats_for_course( - thread["author_id"], - thread["course_id"], - threads=-1, - responses=-count_of_response_deleted, - replies=-count_of_replies_deleted, - deleted_threads=1, - deleted_responses=count_of_response_deleted, - deleted_replies=count_of_replies_deleted, + thread["author_id"], thread["course_id"], threads=-1 ) return serialized_data @@ -406,7 +393,6 @@ 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} @@ -434,64 +420,3 @@ 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 - ) diff --git a/forum/api/users.py b/forum/api/users.py index 19a47fb5..71c3a36e 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -198,7 +198,6 @@ 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)() @@ -252,7 +251,6 @@ 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) diff --git a/forum/backends/backend.py b/forum/backends/backend.py index c281ace2..8a5b9175 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -476,27 +476,3 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: Retrieve all threads and comments authored by a specific user. """ 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 e3b3dcf0..609a9a0e 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1,20 +1,20 @@ -# pylint: disable=cyclic-import """Model util function for db operations.""" import math from datetime import datetime, timezone from typing import Any, Optional -from bson import ObjectId -from bson import errors as bson_errors +from bson import ObjectId, errors as bson_errors from django.core.exceptions import ObjectDoesNotExist from forum.backends.backend import AbstractBackend -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.mongodb import ( + Comment, + CommentThread, + Contents, + Subscriptions, + Users, +) from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -39,9 +39,13 @@ def update_stats_for_course( course_stats = user.get("course_stats", []) for course_stat in course_stats: if course_stat["course_id"] == course_id: - # 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 + course_stat.update( + { + k: course_stat[k] + v + for k, v in kwargs.items() + if k in course_stat + } + ) Users().update( user_id, course_stats=course_stats, @@ -551,7 +555,6 @@ 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. @@ -575,7 +578,6 @@ 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. @@ -596,10 +598,6 @@ 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"] = [ @@ -911,28 +909,6 @@ 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.""" @@ -973,7 +949,6 @@ def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> No "context", "group_id", "group_ids", - "is_deleted", ] if not user_id: valid_params.append("user_id") @@ -1391,9 +1366,6 @@ 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": "", } @@ -1487,51 +1459,10 @@ 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 @@ -1573,6 +1504,8 @@ 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 @@ -1605,17 +1538,6 @@ 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.""" @@ -1649,42 +1571,6 @@ 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.""" - Users().delete_read_state_by_thread_id(thread_id) - 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 create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1815,19 +1701,6 @@ 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] - }, - } - }, {"$sort": sort_criterion}, { "$facet": { @@ -1853,93 +1726,7 @@ def get_paginated_user_stats( @staticmethod def get_contents(**kwargs: Any) -> list[dict[str, Any]]: """Return contents.""" - # 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, - } + return list(Contents().get_list(**kwargs)) @staticmethod def get_user_thread_filter(course_id: str) -> dict[str, Any]: diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 33093954..7f9af685 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -62,9 +62,6 @@ 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( @@ -169,9 +166,6 @@ def update( 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. @@ -216,9 +210,6 @@ def update( ("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 @@ -261,49 +252,30 @@ def update( return result.modified_count - def delete( # type: ignore[override] - self, _id: str, mode: str = "hard", deleted_by: Optional[str] = None - ) -> tuple[int, int]: + def delete(self, _id: str) -> 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, 0 + return 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, mode=mode, deleted_by=deleted_by - ) + child_comments_deleted_count = self.delete_child_comments(_id) - 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 + result = self._collection.delete_one({"_id": ObjectId(_id)}) + if parent_comment_id: + self.update_child_count_in_parent_comment(parent_comment_id, -1) + + no_of_comments_delete = result.deleted_count + child_comments_deleted_count comment_thread_id = comment["comment_thread_id"] self.update_comment_count_in_comment_thread( @@ -315,62 +287,37 @@ def delete( # type: ignore[override] sender=self.__class__, comment_id=_id ) - return result_count, child_comments_deleted_count + return no_of_comments_delete 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, mode: str = "hard", deleted_by: Optional[str] = None - ) -> int: + def delete_child_comments(self, _id: str) -> 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. """ - 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_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 ] - - 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 + child_comments_deleted = self._collection.delete_many( + {"_id": {"$in": child_comment_ids_to_delete}} + ) 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_count + return child_comments_deleted.deleted_count def update_child_count_in_parent_comment(self, parent_id: str, count: int) -> None: """ @@ -420,147 +367,3 @@ 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 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 1b04b081..61126624 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -81,9 +81,6 @@ 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( @@ -211,9 +208,6 @@ def update( 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. @@ -268,9 +262,6 @@ def update( ("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 @@ -310,113 +301,3 @@ 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 diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index b261fd27..c8633476 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -10,8 +10,8 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db.models import ( - Case, Count, + Case, Exists, F, IntegerField, @@ -19,8 +19,8 @@ OuterRef, Q, Subquery, - Sum, When, + Sum, ) from django.utils import timezone from rest_framework import status @@ -62,9 +62,6 @@ 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): @@ -611,7 +608,6 @@ 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. @@ -657,7 +653,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, is_deleted=is_deleted + pk__in=mysql_comment_thread_ids, context=context ) # Group filtering @@ -990,34 +986,6 @@ 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.""" @@ -1405,18 +1373,10 @@ def build_course_stats(cls, author_id: str, course_id: str) -> None: comments_updated_at or timezone.now() - 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() - 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.threads = threads.count() + stats.responses = responses.count() + stats.replies = replies.count() stats.active_flags = active_flags stats.inactive_flags = inactive_flags stats.last_activity_at = updated_at @@ -1490,9 +1450,7 @@ 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, is_deleted=False - ) # Exclude soft deleted comments + comment = Comment.objects.get(pk=comment_id) except Comment.DoesNotExist: return None return comment.to_dict() @@ -1572,179 +1530,6 @@ 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 - @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.""" @@ -1986,20 +1771,6 @@ 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.""" @@ -2140,19 +1911,14 @@ def update_thread( @staticmethod def get_user_thread_filter(course_id: str) -> dict[str, Any]: """Get user thread filter""" - return { - "course_id": course_id, - "is_deleted": False, - } # Exclude soft deleted threads + return {"course_id": course_id} @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).filter( - is_deleted=False - ) # Exclude soft deleted threads + threads = CommentThread.objects.filter(**query) if ids_only: return [{"_id": str(thread.pk)} for thread in threads] return [thread.to_dict() for thread in threads] @@ -2392,14 +2158,8 @@ 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).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 - ) + comments = Comment.objects.filter(**comment_filters) + threads = CommentThread.objects.filter(**thread_filters) sort_key = kwargs.get("sort_key") if sort_key: @@ -2495,57 +2255,3 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: return cls.update_thread(content_id, **update_data) else: return cls.update_comment(content_id, **update_data) - - @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 4ebf76eb..e149daa6 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -63,9 +63,6 @@ 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) ) @@ -82,9 +79,6 @@ 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, "course_id": self.course_id, "last_activity_at": self.last_activity_at, } @@ -135,25 +129,6 @@ class Content(models.Model): 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", @@ -292,8 +267,8 @@ class CommentThread(Content): @property def comment_count(self) -> int: - """Return the number of comments in the thread (excluding deleted).""" - return Comment.objects.filter(comment_thread=self, is_deleted=False).count() + """Return the number of comments in the thread.""" + return Comment.objects.filter(comment_thread=self).count() @classmethod def get(cls, thread_id: str) -> CommentThread: @@ -348,9 +323,6 @@ def to_dict(self) -> dict[str, Any]: "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]: @@ -537,9 +509,6 @@ def to_dict(self) -> dict[str, Any]: "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 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 deleted file mode 100644 index eb679768..00000000 --- a/forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py +++ /dev/null @@ -1,83 +0,0 @@ -# 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/serializers/contents.py b/forum/serializers/contents.py index bb4dcbd7..6fd174b7 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -78,9 +78,6 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): 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/views/comments.py b/forum/views/comments.py index 2dd4bf2b..ed90507c 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_child_comment, create_parent_comment, + create_child_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: - deleted_by: Optional ID of the user performing the delete (defaults to authenticated user). + Empty. Response: The details of the comment that is deleted. """ diff --git a/tests/e2e/test_users.py b/tests/e2e/test_users.py index 893240cf..de1119e2 100644 --- a/tests/e2e/test_users.py +++ b/tests/e2e/test_users.py @@ -96,9 +96,6 @@ def build_structure_and_response( "threads": 0, "responses": 0, "replies": 0, - "deleted_threads": 0, - "deleted_responses": 0, - "deleted_replies": 0, } for author in authors } @@ -508,11 +505,8 @@ def test_handles_deleting_replies( # Thread count should stay the same assert new_stats is not None assert new_stats["threads"] == stats["threads"] - # 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 - ) + assert new_stats["responses"] == stats["responses"] + assert new_stats["replies"] == stats["replies"] - 1 def test_handles_removing_flags( diff --git a/tests/test_backends/test_mongodb/test_comments.py b/tests/test_backends/test_mongodb/test_comments.py index 4b255054..df750922 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, 0) + assert result == 0 result = Comment().delete(comment_id) - assert result == (1, 0) + assert result == 1 comment_data = Comment().get(_id=comment_id) assert comment_data is None diff --git a/tests/test_views/test_comments.py b/tests/test_views/test_comments.py index ff0b89ae..799a6145 100644 --- a/tests/test_views/test_comments.py +++ b/tests/test_views/test_comments.py @@ -1,10 +1,9 @@ """Test comments api endpoints.""" from typing import Any - import pytest -from test_utils.client import APIClient # pylint: disable=import-error +from test_utils.client import APIClient pytestmark = pytest.mark.django_db @@ -122,9 +121,7 @@ def test_update_comment_endorsed_api( def test_delete_parent_comment(api_client: APIClient, patched_get_backend: Any) -> None: """ - Test soft-deleting a parent comment. - - Note: Soft delete marks the comment as deleted (is_deleted=True) but doesn't remove it. + Test deleting a comment. """ backend = patched_get_backend user_id, _, parent_comment_id = setup_models(backend) @@ -140,16 +137,12 @@ 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 - deleted_comment = backend.get_comment(parent_comment_id) - assert deleted_comment is None or deleted_comment.get("is_deleted") is True + assert backend.get_comment(parent_comment_id) is None def test_delete_child_comment(api_client: APIClient, patched_get_backend: Any) -> None: """ - 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). + Test creating a new child comment. """ backend = patched_get_backend user_id, _, parent_comment_id = setup_models(backend) @@ -172,14 +165,13 @@ 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 - deleted_child = backend.get_comment(child_comment_id) - assert deleted_child is None or deleted_child.get("is_deleted") is True + assert backend.get_comment(child_comment_id) is None 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 + assert new_child_count == previous_child_count - 1 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 092596ae..ca28d864 100644 --- a/tests/test_views/test_threads.py +++ b/tests/test_views/test_threads.py @@ -224,12 +224,9 @@ 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 - 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_thread(thread_id) is None + assert backend.get_comment(comment_id_1) is None + assert backend.get_comment(comment_id_2) is None assert backend.get_subscription(subscriber_id=user_id, source_id=thread_id) is None @@ -885,12 +882,9 @@ def test_read_states_deletion_of_a_thread_on_thread_deletion( 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 - 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_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 @@ -1058,12 +1052,9 @@ 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 - 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_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 @@ -1121,8 +1112,7 @@ def test_read_states_deletion_on_thread_deletion_with_multiple_read_states( # 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 - thread = patched_mongo_backend.get_thread(thread_id_1) - assert thread is None or thread.get("is_deleted", False) is True + 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 From 9d492b4cb8a3777ccead5ecd0b5326d1c5741ca9 Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Wed, 14 Jan 2026 15:05:27 +0530 Subject: [PATCH 05/22] feat: added soft delete functionality (#10) Implements soft delete functionality for discussion threads, responses, and comments using the is_deleted flag instead of permanently deleting records. This enables safe deletion and restoration of discussion content while preserving existing data. --- forum/__init__.py | 2 +- forum/api/__init__.py | 13 +- forum/api/comments.py | 96 ++++- forum/api/search.py | 2 + forum/api/threads.py | 83 ++++- forum/api/users.py | 4 +- forum/backends/backend.py | 24 ++ forum/backends/mongodb/api.py | 261 +++++++++++++- forum/backends/mongodb/comments.py | 227 +++++++++++- forum/backends/mongodb/threads.py | 119 +++++++ forum/backends/mysql/api.py | 336 +++++++++++++++++- forum/backends/mysql/models.py | 38 +- ..._deleted_at_comment_deleted_by_and_more.py | 83 +++++ forum/serializers/contents.py | 3 + forum/views/comments.py | 4 +- tests/e2e/test_users.py | 10 +- .../test_mongodb/test_comments.py | 4 +- tests/test_views/test_comments.py | 20 +- tests/test_views/test_threads.py | 30 +- 19 files changed, 1273 insertions(+), 86 deletions(-) create mode 100644 forum/migrations/0006_comment_deleted_at_comment_deleted_by_and_more.py diff --git a/forum/__init__.py b/forum/__init__.py index 44c360c3..bc1fa6c4 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.3.9" +__version__ = "0.4.0" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..5a043360 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -8,14 +8,14 @@ create_parent_comment, delete_comment, get_course_id_by_comment, + get_deleted_comments_for_course, get_parent_comment, 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 ( @@ -28,8 +28,11 @@ create_thread, delete_thread, get_course_id_by_thread, + get_deleted_threads_for_course, get_thread, get_user_threads, + restore_thread, + restore_user_deleted_threads, update_thread, ) from .users import ( @@ -73,6 +76,8 @@ "get_user_course_stats", "get_user_subscriptions", "get_user_threads", + "get_deleted_comments_for_course", + "get_deleted_threads_for_course", "mark_thread_as_read", "pin_thread", "retire_user", diff --git a/forum/api/comments.py b/forum/api/comments.py index 38a9a8bd..7d0b198d 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -220,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: @@ -244,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 @@ -388,3 +411,64 @@ 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 + ) 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 4f14139a..e5795553 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -159,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. """ @@ -177,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: @@ -187,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 @@ -393,6 +406,7 @@ 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} @@ -420,3 +434,64 @@ 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 + ) diff --git a/forum/api/users.py b/forum/api/users.py index 71c3a36e..7bd1d5bb 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -198,6 +198,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 +252,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 +322,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") diff --git a/forum/backends/backend.py b/forum/backends/backend.py index 8a5b9175..c281ace2 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -476,3 +476,27 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: Retrieve all threads and comments authored by a specific user. """ 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 609a9a0e..f4489496 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1,20 +1,20 @@ +# pylint: disable=cyclic-import """Model util function for db operations.""" import math from datetime import datetime, timezone from typing import Any, Optional -from bson import ObjectId, errors as bson_errors +from bson import ObjectId +from bson import errors as bson_errors from django.core.exceptions import ObjectDoesNotExist from forum.backends.backend import AbstractBackend -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.constants import RETIRED_BODY, RETIRED_TITLE from forum.utils import ( ForumV2RequestError, @@ -39,13 +39,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, @@ -555,6 +551,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 +575,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 +596,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 +911,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.""" @@ -949,6 +973,7 @@ def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> No "context", "group_id", "group_ids", + "is_deleted", ] if not user_id: valid_params.append("user_id") @@ -1366,6 +1391,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": "", } @@ -1459,10 +1487,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 @@ -1504,8 +1573,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 @@ -1538,6 +1605,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.""" @@ -1571,6 +1649,42 @@ 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.""" + Users().delete_read_state_by_thread_id(thread_id) + 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 create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1683,6 +1797,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, @@ -1701,6 +1820,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": { @@ -1726,7 +1865,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]: diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 7f9af685..33093954 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( @@ -166,6 +169,9 @@ def update( 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. @@ -210,6 +216,9 @@ def update( ("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 @@ -252,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( @@ -287,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: """ @@ -367,3 +420,147 @@ 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 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 61126624..1b04b081 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( @@ -208,6 +211,9 @@ def update( 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. @@ -262,6 +268,9 @@ def update( ("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 @@ -301,3 +310,113 @@ 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 diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index c8633476..f2b4bb7a 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -10,8 +10,8 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db.models import ( - Count, Case, + Count, Exists, F, IntegerField, @@ -19,8 +19,8 @@ OuterRef, Q, Subquery, - When, Sum, + When, ) from django.utils import timezone from rest_framework import status @@ -62,6 +62,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): @@ -608,6 +611,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 +657,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 +990,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.""" @@ -1373,10 +1405,18 @@ def build_course_stats(cls, author_id: str, course_id: str) -> None: comments_updated_at or timezone.now() - 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 +1490,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 +1572,179 @@ 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 + @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.""" @@ -1771,6 +1986,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.""" @@ -1911,14 +2140,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] @@ -2107,6 +2341,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, @@ -2120,10 +2360,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], ) @@ -2158,8 +2408,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: @@ -2255,3 +2511,57 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: return cls.update_thread(content_id, **update_data) else: return cls.update_comment(content_id, **update_data) + + @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 e149daa6..4d60b480 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -63,6 +63,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 +82,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, } @@ -129,6 +138,25 @@ class Content(models.Model): 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", @@ -267,8 +295,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: @@ -323,6 +351,9 @@ def to_dict(self) -> dict[str, Any]: "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]: @@ -509,6 +540,9 @@ def to_dict(self) -> dict[str, Any]: "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 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/serializers/contents.py b/forum/serializers/contents.py index 6fd174b7..bb4dcbd7 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -78,6 +78,9 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): 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/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/tests/e2e/test_users.py b/tests/e2e/test_users.py index de1119e2..893240cf 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( 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_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..092596ae 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 @@ -882,9 +885,12 @@ def test_read_states_deletion_of_a_thread_on_thread_deletion( 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 + 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 @@ -1052,9 +1058,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 @@ -1112,7 +1121,8 @@ def test_read_states_deletion_on_thread_deletion_with_multiple_read_states( # 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 + thread = patched_mongo_backend.get_thread(thread_id_1) + assert thread is None or thread.get("is_deleted", False) is True 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 From db2c5131ccc68cd604773270c3ee908fd8fe2f07 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Thu, 22 Jan 2026 17:43:28 +0530 Subject: [PATCH 06/22] fix: remove read state deletion in soft delete of thread (#12) This PR removes the read state deletion from the soft-delete thread operation in the MongoDB backend to preserve user read states when threads are soft-deleted, enabling proper restoration and improving performance by eliminating a 5-minute operation. --- forum/__init__.py | 2 +- forum/backends/mongodb/api.py | 1 - tests/test_views/test_threads.py | 97 -------------------------------- 3 files changed, 1 insertion(+), 99 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index bc1fa6c4..255a215f 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index f4489496..bcaa851c 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1652,7 +1652,6 @@ def delete_thread(thread_id: str) -> int: @staticmethod def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: """Soft delete thread by marking it as deleted.""" - Users().delete_read_state_by_thread_id(thread_id) return CommentThread().update( thread_id, is_deleted=True, deleted_at=datetime.now(), deleted_by=deleted_by ) diff --git a/tests/test_views/test_threads.py b/tests/test_views/test_threads.py index 092596ae..da858c85 100644 --- a/tests/test_views/test_threads.py +++ b/tests/test_views/test_threads.py @@ -857,49 +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 - 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 - ) - 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}) @@ -1073,60 +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 - thread = patched_mongo_backend.get_thread(thread_id_1) - assert thread is None or thread.get("is_deleted", False) is True - 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: From 20e90bf8b6b9089fe9483ee143cc645b3130b7f8 Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Mon, 9 Feb 2026 15:33:04 +0530 Subject: [PATCH 07/22] feat: auto deletion of spam via AI (#15) This PR adds automated deletion of spam forum content detected by AI moderation. The feature is controlled by a new waffle flag discussions.enable_ai_auto_delete_spam and works in conjunction with the existing AI moderation system. --- forum/__init__.py | 2 +- forum/ai_moderation.py | 66 ++++- forum/toggles.py | 18 ++ tests/test_ai_moderation.py | 485 ++++++++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 tests/test_ai_moderation.py diff --git a/forum/__init__.py b/forum/__init__.py index 255a215f..1257036d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/forum/ai_moderation.py b/forum/ai_moderation.py index 5e9233e8..0f7a1816 100644 --- a/forum/ai_moderation.py +++ b/forum/ai_moderation.py @@ -9,10 +9,13 @@ 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__) @@ -218,6 +221,7 @@ def moderate_and_flag_content( # 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 @@ -246,16 +250,24 @@ def moderate_and_flag_content( ) if is_spam: + # Flag content as spam and abuse first try: content_instance["is_spam"] = True - self._mark_as_spam_and_flag_abuse(content_instance, backend) - + 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"] @@ -269,7 +281,7 @@ def moderate_and_flag_content( ) return result - def _mark_as_spam_and_flag_abuse(self, content_instance: Any, backend: Any) -> None: + 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")) @@ -278,15 +290,45 @@ def _mark_as_spam_and_flag_abuse(self, content_instance: Any, backend: Any) -> N "CommentThread" if content_type == "CommentThread" else "Comment" ) } - try: - 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 - ) - except (AttributeError, ValueError, TypeError, ImportError) as e: - log.error(f"Failed to flag content via backend: {e}") + 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 diff --git a/forum/toggles.py b/forum/toggles.py index 013810f6..d4f031dc 100644 --- a/forum/toggles.py +++ b/forum/toggles.py @@ -29,6 +29,24 @@ 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] """ 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 From 02de16c80a5e54627b87b0d46aa61aecef6f7efd Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Mon, 16 Feb 2026 15:12:34 +0530 Subject: [PATCH 08/22] fix: reported tag remained visible after reported response/comment was deleted (#17) When a comment or response is reported, a "Reported" badge appears in the post sidebar to alert moderators. However, if the reported content is deleted, the badge remains visible even though there's nothing left to review, causing confusion. The badge should only appear when there's at least one reported comment that hasn't been deleted. Once all reported comments are removed, the badge should disappear automatically. --- forum/__init__.py | 2 +- forum/backends/mongodb/api.py | 2 ++ forum/backends/mysql/api.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/forum/__init__.py b/forum/__init__.py index 1257036d..15fb549a 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.2" +__version__ = "0.4.3" diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index bcaa851c..3bec9249 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -411,6 +411,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. @@ -423,6 +424,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}}}, diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index f2b4bb7a..ca3d8524 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -397,6 +397,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. @@ -417,6 +418,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( From 266f5c2c38d6a1f838dd8551ed1c7e057e6778b9 Mon Sep 17 00:00:00 2001 From: Maniraja Raman Date: Tue, 10 Feb 2026 08:34:58 +0000 Subject: [PATCH 09/22] feat: bulkdelete ban feature in forum --- forum/__init__.py | 2 +- forum/api/__init__.py | 18 + forum/api/bans.py | 692 ++++++++++++++++++ forum/backends/mysql/models.py | 513 +++++++++++-- .../0007_add_discussion_ban_models.py | 486 ++++++++++++ forum/serializers/bans.py | 172 +++++ forum/urls.py | 34 +- forum/views/bans.py | 248 +++++++ requirements/base.in | 1 + tests/test_api_bans.py | 567 ++++++++++++++ .../test_mysql/test_mysql_ban_models.py | 527 +++++++++++++ tests/test_views/test_bans.py | 414 +++++++++++ 12 files changed, 3620 insertions(+), 54 deletions(-) create mode 100644 forum/api/bans.py create mode 100644 forum/migrations/0007_add_discussion_ban_models.py create mode 100644 forum/serializers/bans.py create mode 100644 forum/views/bans.py create mode 100644 tests/test_api_bans.py create mode 100644 tests/test_backends/test_mysql/test_mysql_ban_models.py create mode 100644 tests/test_views/test_bans.py diff --git a/forum/__init__.py b/forum/__init__.py index 15fb549a..63ea5f32 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.3" +__version__ = "0.4.4" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 5a043360..a35680cc 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -2,6 +2,16 @@ 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, @@ -54,6 +64,8 @@ ) __all__ = [ + "ban_user", + "create_audit_log", "create_child_comment", "create_parent_comment", "create_subscription", @@ -64,6 +76,9 @@ "delete_subscription", "delete_thread", "delete_thread_vote", + "get_ban", + "get_banned_users", + "get_banned_usernames", "get_commentables_stats", "get_course_id_by_comment", "get_course_id_by_thread", @@ -72,16 +87,19 @@ "get_thread_subscriptions", "get_user", "get_user_active_threads", + "get_user_ban_scope", "get_user_comments", "get_user_course_stats", "get_user_subscriptions", "get_user_threads", + "is_user_banned", "get_deleted_comments_for_course", "get_deleted_threads_for_course", "mark_thread_as_read", "pin_thread", "retire_user", "search_threads", + "unban_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..dad12e66 --- /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, # type: ignore[attr-defined] + 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, # type: ignore[attr-defined] + "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, # type: ignore[attr-defined] + "ban_id": ban.id, # type: ignore[attr-defined] + "course_id": str(course_id), + "unbanned_by": moderator.username if moderator else None, # type: ignore[attr-defined] + "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, # type: ignore[attr-defined] + "exception_id": exception.id, # type: ignore[attr-defined] + "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, # type: ignore[attr-defined] + }, + # 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, + moderator.id if moderator else None, # type: ignore[attr-defined] + ) + + 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, # type: ignore[attr-defined] + "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/backends/mysql/models.py b/forum/backends/mysql/models.py index 4d60b480..c8bdbf45 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 @@ -823,85 +828,232 @@ class Meta: class ModerationAuditLog(models.Model): - """Audit log for AI moderation decisions on spam content.""" + """ + 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" - # Available actions that can be taken on spam content ACTION_CHOICES = [ - ("flagged", "Content Flagged"), - ("soft_deleted", "Content Soft Deleted"), - ("no_action", "No Action Taken"), + # 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"), ] - # Only spam classifications since we don't store non-spam entries + # AI classification types (only for AI moderation) + CLASSIFICATION_SPAM = "spam" + CLASSIFICATION_SPAM_OR_SCAM = "spam_or_scam" CLASSIFICATION_CHOICES = [ - ("spam", "Spam"), - ("spam_or_scam", "Spam or Scam"), + (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, help_text="When the moderation decision was made" + 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", + ) ) - body: models.TextField[str, str] = models.TextField( - help_text="The content body that was moderated" + + # === 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", ) - classifier_output: models.JSONField[dict[str, Any], dict[str, Any]] = ( - models.JSONField(help_text="Full output from the AI classifier") + + # === 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", ) - reasoning: models.TextField[str, str] = models.TextField( - help_text="AI reasoning for the decision" + + # === 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[str, str] = models.CharField( + 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[list[str], list[str]] = models.JSONField( - default=list, - help_text="List of actions taken based on moderation (e.g., ['flagged', 'soft_deleted'])", + 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" + 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" + default=False, + help_text="Whether a human moderator overrode the AI decision", ) override_reason: models.TextField[Optional[str], str] = models.TextField( - blank=True, null=True, help_text="Reason for moderator override" - ) - moderator: models.ForeignKey[User, User] = models.ForeignKey( - User, null=True, blank=True, - on_delete=models.SET_NULL, - related_name="moderation_actions", - help_text="Human moderator who made override", + help_text="Reason for moderator override", ) - original_author: models.ForeignKey[User, User] = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="moderated_content", - help_text="Original author of the moderated content", + + # === 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.""" - return { + data: dict[str, Any] = { "_id": str(self.pk), + "action_type": self.action_type, + "source": self.source, "timestamp": self.timestamp.isoformat(), - "body": self.body, - "classifier_output": self.classifier_output, - "reasoning": self.reasoning, - "classification": self.classification, - "actions_taken": self.actions_taken, - "confidence_score": self.confidence_score, - "moderator_override": self.moderator_override, - "override_reason": self.override_reason, "moderator_id": str(self.moderator.pk) if self.moderator else None, "moderator_username": self.moderator.username if self.moderator else None, - "original_author_id": str(self.original_author.pk), - "original_author_username": self.original_author.username, + "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" @@ -909,7 +1061,276 @@ class Meta: 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"]), ] + + +# ============================================================================== +# 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")), + ] + + # 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 + """ + + # 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") + ) 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/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/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/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/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_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() From 67acca97da541314dc038e5bf405bb627629400b Mon Sep 17 00:00:00 2001 From: Chintan Joshi Date: Tue, 24 Mar 2026 11:21:37 +0530 Subject: [PATCH 10/22] fix: optimize mongo query for user vote list (#19) * chore: bump version * fix: user vote query optimization * fix: e2e test case not related to this PR --- forum/__init__.py | 2 +- forum/backends/backend.py | 4 +++- forum/backends/mongodb/api.py | 23 ++++++++++++++++------- forum/backends/mysql/api.py | 4 +++- tests/e2e/test_users.py | 7 ++++++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 63ea5f32..fb5dbe93 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.4" +__version__ = "0.4.5" diff --git a/forum/backends/backend.py b/forum/backends/backend.py index c281ace2..d624f240 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 diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 3bec9249..35a5b116 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1159,18 +1159,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 @@ -1207,8 +1212,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, diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index ca3d8524..51a3342a 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1132,7 +1132,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") diff --git a/tests/e2e/test_users.py b/tests/e2e/test_users.py index 893240cf..45323608 100644 --- a/tests/e2e/test_users.py +++ b/tests/e2e/test_users.py @@ -624,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, ) From 040add10fb8bb5e3bec86e77f3e3ad9209ad57a1 Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Wed, 1 Apr 2026 16:45:18 +0530 Subject: [PATCH 11/22] feat: implement discussion mute/unmute feature with user and staff-level controls (#7) This update introduces a comprehensive Mute / Unmute feature for discussion forums, enabling learners and staff to manage unwanted interactions more effectively while maintaining a healthy learning environment. The feature supports both personal and course-wide mute scopes, with clear role-based restrictions and overrides. The implementation ensures muted content is hidden retroactively as well as for future posts, without notifying muted users. Special handling is included to prevent learners from muting staff or themselves, while giving staff full moderation control across the course. --- forum/__init__.py | 2 +- forum/admin.py | 24 + forum/api/__init__.py | 12 + forum/api/bans.py | 20 +- forum/api/users.py | 315 ++++++++++- forum/backend.py | 3 +- forum/backends/backend.py | 149 ++++++ forum/backends/mongodb/api.py | 504 +++++++++++++++++- forum/backends/mysql/api.py | 359 ++++++++++++- forum/backends/mysql/models.py | 132 +++++ .../0008_discussionmuterecord_and_more.py | 181 +++++++ 11 files changed, 1685 insertions(+), 16 deletions(-) create mode 100644 forum/migrations/0008_discussionmuterecord_and_more.py diff --git a/forum/__init__.py b/forum/__init__.py index fb5dbe93..17971dbc 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.5" +__version__ = "0.4.6" diff --git a/forum/admin.py b/forum/admin.py index c1ff0239..a6029925 100644 --- a/forum/admin.py +++ b/forum/admin.py @@ -15,6 +15,7 @@ Subscription, MongoContent, ModerationAuditLog, + DiscussionMuteRecord, ) @@ -149,6 +150,29 @@ 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.""" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index a35680cc..1179178e 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -47,11 +47,17 @@ ) 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, @@ -110,4 +116,10 @@ "update_user", "update_username", "update_users_in_course", + "mute_user", + "unmute_user", + "get_user_mute_status", + "get_muted_users", + "get_all_muted_users_for_course", + "mute_and_report_user", ] diff --git a/forum/api/bans.py b/forum/api/bans.py index dad12e66..a75e36e2 100644 --- a/forum/api/bans.py +++ b/forum/api/bans.py @@ -136,7 +136,7 @@ def ban_user( log.info( "Cleaned up %d orphaned exception(s) for org ban: ban_id=%s, user_id=%s", deleted_count, - ban.id, # type: ignore[attr-defined] + ban.id, banned_user.id, # type: ignore[attr-defined] ) @@ -150,7 +150,7 @@ def ban_user( scope=scope, reason=reason, metadata={ - "ban_id": ban.id, # type: ignore[attr-defined] + "ban_id": ban.id, "created": created, }, # AI moderation fields (required by schema, not applicable for ban actions) @@ -263,10 +263,10 @@ def unban_user( exception_created = True exception_data = { - "id": exception.id, # type: ignore[attr-defined] - "ban_id": ban.id, # type: ignore[attr-defined] + "id": exception.id, + "ban_id": ban.id, "course_id": str(course_id), - "unbanned_by": moderator.username if moderator else None, # type: ignore[attr-defined] + "unbanned_by": moderator.get_username() if moderator else None, "reason": exception.reason, "created_at": ( exception.created.isoformat() @@ -290,8 +290,8 @@ def unban_user( scope="organization", reason=f"Exception to org ban: {reason}", metadata={ - "ban_id": ban.id, # type: ignore[attr-defined] - "exception_id": exception.id, # type: ignore[attr-defined] + "ban_id": ban.id, + "exception_id": exception.id, "exception_created": created, "org_key": ban.org_key, }, @@ -324,7 +324,7 @@ def unban_user( scope=ban.scope, reason=f"Unban: {reason}", metadata={ - "ban_id": ban.id, # type: ignore[attr-defined] + "ban_id": ban.id, }, # AI moderation fields (required by schema, not applicable for ban actions) body="", @@ -342,7 +342,7 @@ def unban_user( ban_id, ban.user.id, exception_created, - moderator.id if moderator else None, # type: ignore[attr-defined] + getattr(moderator, "id", None) if moderator else None, ) return { @@ -485,7 +485,7 @@ def _serialize_ban(ban: DiscussionBan) -> dict[str, Any]: dict: Serialized ban data """ return { - "id": ban.id, # type: ignore[attr-defined] + "id": ban.id, "user": { "id": ban.user.id, "username": ban.user.username, diff --git a/forum/api/users.py b/forum/api/users.py index 7bd1d5bb..9ebd808c 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -4,8 +4,10 @@ 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.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE from forum.serializers.thread import ThreadSerializer @@ -372,3 +374,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 d624f240..9f6503ae 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -479,6 +479,155 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: """ 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, diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 35a5b116..1f2ced2c 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -3,11 +3,14 @@ import math from datetime import datetime, timezone +from functools import wraps from typing import Any, Optional from bson import ObjectId from bson import errors as bson_errors -from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.utils.timezone import now from forum.backends.backend import AbstractBackend from forum.backends.mongodb.comments import Comment @@ -15,6 +18,7 @@ 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 +28,78 @@ 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) -> bool: + """Check if user has any privileges""" + # Basic Django privileges + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + # Check if user has any forum role or course role + return ( + hasattr(user, "role_set") + and user.role_set.exists() + or hasattr(user, "courseaccessrole_set") + and user.courseaccessrole_set.exists() + ) + @classmethod def update_stats_for_course( cls, user_id: str, course_id: str, **kwargs: Any @@ -2046,3 +2118,433 @@ def unflag_content_as_spam(content_type: str, content_id: str) -> int: 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") + + if cls.user_has_privileges(muted_user): + raise ValueError("Staff and privileged users cannot be muted") + + 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)) + viewer = ( + User.objects.get(pk=int(requesting_user_id)) + if requesting_user_id + else None + ) + + # Check for active mutes + personal_mutes = DiscussionMuteRecord.objects.filter( + muted_user=user, + muted_by=viewer, + course_id=course_id, + scope=DiscussionMuteRecord.Scope.PERSONAL, + is_active=True, + ) + + course_mutes = DiscussionMuteRecord.objects.filter( + muted_user=user, + course_id=course_id, + scope=DiscussionMuteRecord.Scope.COURSE, + is_active=True, + ) + + is_personally_muted = personal_mutes.exists() + is_course_muted = course_mutes.exists() + + return { + "user_id": muted_user_id, + "course_id": course_id, + "is_muted": is_personally_muted or is_course_muted, + "personal_mute": is_personally_muted, + "course_mute": is_course_muted, + "mute_details": [mute.to_dict() for mute in personal_mutes] + + [mute.to_dict() for mute in course_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/mysql/api.py b/forum/backends/mysql/api.py index 51a3342a..f55acf16 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -3,11 +3,12 @@ import math import random from datetime import timedelta -from typing import Any, Optional, Union +from functools import wraps +from typing import Any, Dict, Optional, Union, Callable, TypeVar 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 ( Case, @@ -32,6 +33,7 @@ Comment, CommentThread, CourseStat, + DiscussionMuteRecord, EditHistory, ForumUser, HistoricalAbuseFlagger, @@ -43,10 +45,46 @@ 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") + + if cls.user_has_privileges(muted_user): + raise ValidationError("Staff and privileged users cannot be muted") + + return muted_user, muted_by_user + @classmethod def update_stats_for_course( cls, user_id: str, course_id: str, **kwargs: Any @@ -86,6 +124,21 @@ def _get_entity_from_type( except ObjectDoesNotExist: return None + @staticmethod + def user_has_privileges(user: object) -> bool: + """Check if user has any privileges""" + # Basic Django privileges + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + # Check if user has any forum role or course role + return ( + hasattr(user, "role_set") + and user.role_set.exists() + or hasattr(user, "courseaccessrole_set") + and user.courseaccessrole_set.exists() + ) + @classmethod def flag_as_abuse( cls, user_id: str, entity_id: str, **kwargs: Any @@ -2516,6 +2569,308 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: else: return cls.update_comment(content_id, **update_data) + # 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) + + 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() + 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 + ) + 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 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() + + 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, + ) + + # 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)) + viewer = ( + User.objects.get(pk=int(requesting_user_id)) if requesting_user_id else None + ) + + # Check for active mutes + personal_mutes = DiscussionMuteRecord.objects.filter( + muted_user=user, + muted_by=viewer, + course_id=course_id, + scope=DiscussionMuteRecord.Scope.PERSONAL, + is_active=True, + ) + + course_mutes = DiscussionMuteRecord.objects.filter( + muted_user=user, + course_id=course_id, + scope=DiscussionMuteRecord.Scope.COURSE, + is_active=True, + ) + + is_personally_muted = personal_mutes.exists() + is_course_muted = course_mutes.exists() + + return { + "user_id": muted_user_id, + "course_id": course_id, + "is_muted": is_personally_muted or is_course_muted, + "personal_mute": is_personally_muted, + "course_mute": is_course_muted, + "mute_details": [mute.to_dict() for mute in personal_mutes] + + [mute.to_dict() for mute in course_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, diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index c8bdbf45..637cd629 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -870,6 +870,12 @@ class ModerationAuditLog(models.Model): (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) @@ -1068,6 +1074,9 @@ class Meta: 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"]), ] @@ -1100,6 +1109,8 @@ class DiscussionBan(TimeStampedModel): (SCOPE_ORGANIZATION, _("Organization")), ] + id: int + # Core Fields user = models.ForeignKey( User, @@ -1287,6 +1298,8 @@ class DiscussionBanException(TimeStampedModel): - User can participate in CS50 but remains banned in all other HarvardX courses """ + id: int + # Core Fields ban = models.ForeignKey( "DiscussionBan", @@ -1334,3 +1347,122 @@ def clean(self): 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/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", + ), + ), + ] From af5f40c457cef7b376f4725422b6c9abceb7f60b Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Tue, 7 Apr 2026 14:54:40 +0530 Subject: [PATCH 12/22] feat: implement bulk delete for MySQL database (#23) Bulk delete functionality is currently implemented only for the MongoDB backend, while the corresponding implementation for the MySQL backend is still pending. As part of the migration from MongoDB to MySQL, it is essential to implement bulk delete support in the MySQL backend to ensure feature parity and consistent behavior across both systems. --- forum/__init__.py | 2 +- forum/api/__init__.py | 28 +- forum/api/comments.py | 35 ++ forum/api/threads.py | 35 ++ forum/backends/mongodb/api.py | 42 ++ forum/backends/mongodb/comments.py | 37 ++ forum/backends/mongodb/threads.py | 37 ++ forum/backends/mysql/api.py | 145 ++++++ tests/test_backends/test_mysql/test_api.py | 543 ++++++++++++++++++++- 9 files changed, 894 insertions(+), 10 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 17971dbc..e9965e4b 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.6" +__version__ = "0.4.7" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 1179178e..ef6ed3b9 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -17,9 +17,11 @@ 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, @@ -37,10 +39,12 @@ 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, @@ -82,30 +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", - "get_deleted_comments_for_course", - "get_deleted_threads_for_course", "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", @@ -116,10 +134,4 @@ "update_user", "update_username", "update_users_in_course", - "mute_user", - "unmute_user", - "get_user_mute_status", - "get_muted_users", - "get_all_muted_users_for_course", - "mute_and_report_user", ] diff --git a/forum/api/comments.py b/forum/api/comments.py index 7d0b198d..c6838461 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -472,3 +472,38 @@ def restore_user_deleted_comments( 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/threads.py b/forum/api/threads.py index e5795553..b26c71bb 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -495,3 +495,38 @@ def restore_user_deleted_threads( 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/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 1f2ced2c..2e065942 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1767,6 +1767,48 @@ def 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.""" diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 33093954..527fc0b0 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -564,3 +564,40 @@ def restore_user_deleted_comments( 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 1b04b081..9b5da85f 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -420,3 +420,40 @@ def restore_user_deleted_threads( 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 f55acf16..bd0e2edc 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1802,6 +1802,151 @@ def restore_user_deleted_threads( 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.""" 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 From 82e25d5dd99b228359d7ad2885740f43592a48ef Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Wed, 8 Apr 2026 18:24:35 +0530 Subject: [PATCH 13/22] fix: add missing audit log for muted learner in mysql table (#24) Adds the missing audit log entry for muted learners in the MySQL table to ensure all mute actions are properly tracked. This improves visibility and consistency in learner moderation records. --- forum/__init__.py | 2 +- forum/backends/mongodb/api.py | 51 ++++++++------ forum/backends/mysql/api.py | 128 +++++++++++++++++++++++++++------- 3 files changed, 133 insertions(+), 48 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index e9965e4b..869aef47 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.7" +__version__ = "0.4.8" diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 2e065942..975e668b 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -10,6 +10,7 @@ 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 @@ -2413,39 +2414,45 @@ def get_user_mute_status( @cls._handle_mute_errors def _get_status_operation() -> dict[str, Any]: user = User.objects.get(pk=int(muted_user_id)) - viewer = ( - User.objects.get(pk=int(requesting_user_id)) - if requesting_user_id - else None - ) - # Check for active mutes - personal_mutes = DiscussionMuteRecord.objects.filter( + # Optimize: Use single query to get all active mutes for this user in this course + mutes_query = DiscussionMuteRecord.objects.filter( muted_user=user, - muted_by=viewer, course_id=course_id, - scope=DiscussionMuteRecord.Scope.PERSONAL, is_active=True, ) - course_mutes = DiscussionMuteRecord.objects.filter( - muted_user=user, - course_id=course_id, - scope=DiscussionMuteRecord.Scope.COURSE, - 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 + ) - is_personally_muted = personal_mutes.exists() - is_course_muted = course_mutes.exists() + # 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": is_personally_muted or is_course_muted, - "personal_mute": is_personally_muted, - "course_mute": is_course_muted, - "mute_details": [mute.to_dict() for mute in personal_mutes] - + [mute.to_dict() for mute in course_mutes], + "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() diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index bd0e2edc..087df1a4 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1,8 +1,8 @@ """Client backend for forum v2.""" +import datetime as dt import math import random -from datetime import timedelta from functools import wraps from typing import Any, Dict, Optional, Union, Callable, TypeVar @@ -38,6 +38,7 @@ ForumUser, HistoricalAbuseFlagger, LastReadTime, + ModerationAuditLog, ReadState, Subscription, UserVote, @@ -1458,8 +1459,8 @@ 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 @@ -2714,6 +2715,37 @@ def unflag_content_as_spam(cls, content_type: str, content_id: str) -> int: 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 @@ -2769,6 +2801,18 @@ def mute_user( ) 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 @@ -2806,9 +2850,9 @@ def unmute_user( 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: - muted_by_user = User.objects.get(pk=int(muter_id)) - mute_query = mute_query.filter(muted_by=muted_by_user) + mute_query = mute_query.filter(muted_by__pk=int(muter_id)) mute = mute_query.first() if not mute: @@ -2830,6 +2874,16 @@ def unmute_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), @@ -2868,8 +2922,26 @@ def mute_and_report_user( 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" @@ -2896,37 +2968,43 @@ def get_user_mute_status( Dictionary containing mute status information """ user = User.objects.get(pk=int(muted_user_id)) - viewer = ( - User.objects.get(pk=int(requesting_user_id)) if requesting_user_id else None - ) - # Check for active mutes - personal_mutes = DiscussionMuteRecord.objects.filter( + # Optimize: Use single query to get all active mutes for this user in this course + mutes_query = DiscussionMuteRecord.objects.filter( muted_user=user, - muted_by=viewer, course_id=course_id, - scope=DiscussionMuteRecord.Scope.PERSONAL, is_active=True, ) - course_mutes = DiscussionMuteRecord.objects.filter( - muted_user=user, - course_id=course_id, - scope=DiscussionMuteRecord.Scope.COURSE, - 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) - is_personally_muted = personal_mutes.exists() - is_course_muted = course_mutes.exists() + # 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": is_personally_muted or is_course_muted, - "personal_mute": is_personally_muted, - "course_mute": is_course_muted, - "mute_details": [mute.to_dict() for mute in personal_mutes] - + [mute.to_dict() for mute in course_mutes], + "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 From d073d1ea58f3f8081231f19ee2c7229f9cda1f04 Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Fri, 10 Apr 2026 11:56:49 +0530 Subject: [PATCH 14/22] fix: restrict mute privileges to discussion moderators only (#27) Fixed discussion moderation permissions to restrict delete, ban, mute, and restore operations to discussion moderators only. Course staff and course instructors were incorrectly granted full moderation privileges when mute feature was added - they are authoring roles and should not have discussion moderation access. --- forum/backends/mongodb/api.py | 60 +++++++++++++++++++----- forum/backends/mysql/api.py | 86 +++++++++++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 20 deletions(-) diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 975e668b..ef28675f 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -87,19 +87,50 @@ def _create_audit_log( pass @staticmethod - def user_has_privileges(user: object) -> bool: - """Check if user has any privileges""" - # Basic Django privileges + 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 if user has any forum role or course role - return ( - hasattr(user, "role_set") - and user.role_set.exists() - or hasattr(user, "courseaccessrole_set") - and user.courseaccessrole_set.exists() - ) + # 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( @@ -2197,9 +2228,14 @@ def _mute_operation() -> dict[str, Any]: if muted_user.pk == muted_by_user.pk: raise ValueError("Users cannot mute themselves") - if cls.user_has_privileges(muted_user): - raise ValueError("Staff and privileged users cannot be muted") + # 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 ) diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 087df1a4..63102e44 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -4,7 +4,7 @@ import math import random from functools import wraps -from typing import Any, Dict, Optional, Union, Callable, TypeVar +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 @@ -81,9 +81,6 @@ def _validate_mute_users( if muted_user.pk == muted_by_user.pk: raise ValidationError("Users cannot mute themselves") - if cls.user_has_privileges(muted_user): - raise ValidationError("Staff and privileged users cannot be muted") - return muted_user, muted_by_user @classmethod @@ -126,13 +123,35 @@ def _get_entity_from_type( return None @staticmethod - def user_has_privileges(user: object) -> bool: - """Check if user has any privileges""" - # Basic Django privileges + 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 if user has any forum role or course role + # 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() @@ -140,6 +159,49 @@ def user_has_privileges(user: object) -> bool: 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 @@ -2775,6 +2837,14 @@ def mute_user( """ 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 ) From 7050d61a1574b4f4e5a17f166e26966ced1e055c Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Fri, 10 Apr 2026 19:23:15 +0530 Subject: [PATCH 15/22] fix: migrate soft delete fields and support updating existing content (#28) The migration script did not handle soft-deleted fields, resulting in deleted content not being correctly migrated. Updated the script to include soft-deleted records and ensure proper migration of deleted content. --- forum/__init__.py | 2 +- forum/migration_helpers.py | 120 ++++++++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 869aef47..9111a804 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.8" +__version__ = "0.4.9" 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() From 45352228ce3f1595b1f5e44e2d1937afbd57af93 Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Mon, 13 Apr 2026 17:01:33 +0530 Subject: [PATCH 16/22] feat: add instrumentation to forum (#29) --- forum/backend.py | 12 +- forum/backends/mongodb/comments.py | 20 ++ forum/backends/mongodb/threads.py | 13 + forum/backends/mysql/api.py | 53 ++++ forum/backends/mysql/models.py | 2 +- forum/migration_helpers.py | 1 - forum/settings/common.py | 3 + forum/views/comments.py | 41 ++- forum/views/threads.py | 40 +++ forum/views/votes.py | 29 +- requirements/base.in | 2 + requirements/base.txt | 128 +++++--- requirements/ci.txt | 262 +++++++++------ requirements/dev.txt | 299 ++++++++++++------ requirements/doc.txt | 239 +++++++++----- requirements/pip-tools.txt | 16 +- requirements/pip.txt | 14 +- requirements/quality.txt | 254 +++++++++------ requirements/test.txt | 208 ++++++++---- setup.py | 1 + .../test_mongodb/test_comments.py | 1 + .../test_commands/test_migration_commands.py | 1 - 22 files changed, 1139 insertions(+), 500 deletions(-) diff --git a/forum/backend.py b/forum/backend.py index 194f5113..b939503e 100644 --- a/forum/backend.py +++ b/forum/backend.py @@ -2,6 +2,8 @@ from typing import Callable, Optional +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] + from forum.backends.mongodb.api import MongoBackend from forum.backends.mysql.api import MySQLBackend @@ -37,7 +39,15 @@ def get_backend( """Return a factory function that lazily loads the backend API based on course_id.""" def _get_backend() -> MongoBackend | MySQLBackend: - if is_mysql_backend_enabled(course_id): + backend_enabled = is_mysql_backend_enabled(course_id) + + # Track which backend is being used + backend_type = "mysql" if backend_enabled else "mongodb" + set_custom_attribute("forum.backend", backend_type) + if course_id: + set_custom_attribute("forum.backend.course_id", str(course_id)) + + if backend_enabled: return MySQLBackend() return MongoBackend() diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 527fc0b0..32d313b8 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -4,6 +4,7 @@ from typing import Any, Optional from bson import ObjectId +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.threads import CommentThread @@ -102,6 +103,18 @@ def insert( Returns: str: The ID of the inserted document. """ + # Track comment insertion + set_custom_attribute("forum.backend.operation", "insert_comment") + set_custom_attribute("forum.thread_id", comment_thread_id) + set_custom_attribute("forum.course_id", course_id) + set_custom_attribute("forum.author_id", author_id) + set_custom_attribute("forum.comment_depth", str(depth)) + if parent_id: + set_custom_attribute("forum.parent_comment_id", parent_id) + set_custom_attribute("forum.is_child_comment", True) + else: + set_custom_attribute("forum.is_child_comment", False) + date = datetime.now() comment_data = { "votes": self.get_votes_dict(up=[], down=[]), @@ -275,6 +288,13 @@ def delete( # type: ignore[override] Returns: The number of comments deleted. """ + # Track comment deletion + set_custom_attribute("forum.backend.operation", "delete_comment") + set_custom_attribute("forum.comment_id", _id) + set_custom_attribute("forum.delete_mode", mode) + if deleted_by: + set_custom_attribute("forum.deleted_by", deleted_by) + comment = self.get(_id) if not comment: return 0, 0 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 9b5da85f..3e392113 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -4,6 +4,7 @@ from typing import Any, Optional from bson import ObjectId +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.users import Users @@ -20,6 +21,9 @@ class CommentThread(BaseContents): def delete(self, _id: str) -> int: """Delete CommentThread""" + set_custom_attribute("forum.backend.operation", "delete_thread") + set_custom_attribute("forum.thread_id", _id) + result = super().delete(_id) get_handler_by_name("comment_thread_deleted").send( sender=self.__class__, comment_thread_id=_id @@ -142,6 +146,15 @@ def insert( if historical_abuse_flaggers is None: historical_abuse_flaggers = [] + # Track thread insertion + set_custom_attribute("forum.backend.operation", "insert_thread") + set_custom_attribute("forum.thread_type", thread_type) + set_custom_attribute("forum.course_id", course_id) + set_custom_attribute("forum.commentable_id", commentable_id) + set_custom_attribute("forum.author_id", author_id) + if group_id: + set_custom_attribute("forum.group_id", str(group_id)) + date = datetime.now() thread_data = { "votes": self.get_votes_dict(up=[], down=[]), diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 63102e44..ee161a2c 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -24,6 +24,7 @@ When, ) from django.utils import timezone +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.response import Response @@ -1686,6 +1687,10 @@ def create_comment(cls, data: dict[str, Any]) -> str: @classmethod def delete_comment(cls, comment_id: str) -> None: """Delete comment from comment_id.""" + set_custom_attribute("forum.backend.operation", "delete_comment") + set_custom_attribute("forum.comment_id", comment_id) + set_custom_attribute("forum.delete_mode", "hard") + comment = Comment.objects.get(pk=comment_id) if comment.parent: cls.update_child_count_in_parent_comment(str(comment.parent.pk), -1) @@ -1701,6 +1706,12 @@ def soft_delete_comment( Returns: tuple: (responses_deleted, replies_deleted) """ + set_custom_attribute("forum.backend.operation", "delete_comment") + set_custom_attribute("forum.comment_id", comment_id) + set_custom_attribute("forum.delete_mode", "soft") + if deleted_by: + set_custom_attribute("forum.deleted_by", deleted_by) + comment = Comment.objects.get(pk=comment_id) deleted_user: Optional[User] = None if deleted_by: @@ -2044,6 +2055,17 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: @staticmethod def update_comment(comment_id: str, **kwargs: Any) -> int: """Updates a comment in the database.""" + # Track comment update + set_custom_attribute("forum.backend.operation", "update_comment") + set_custom_attribute("forum.comment_id", comment_id) + + # Track what's being updated + update_fields = [k for k in kwargs if kwargs.get(k) is not None] + if update_fields: + set_custom_attribute("forum.update_fields", ",".join(update_fields)) + if "course_id" in kwargs: + set_custom_attribute("forum.course_id", kwargs["course_id"]) + try: comment = Comment.objects.get(id=comment_id) except Comment.DoesNotExist: @@ -2244,6 +2266,9 @@ def get_subscriptions(cls, query: dict[str, Any]) -> list[dict[str, Any]]: @staticmethod def delete_thread(thread_id: str) -> int: """Delete thread from thread_id.""" + set_custom_attribute("forum.backend.operation", "delete_thread") + set_custom_attribute("forum.thread_id", thread_id) + try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2254,6 +2279,12 @@ def delete_thread(thread_id: str) -> int: @staticmethod def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: """Soft delete thread by marking it as deleted.""" + set_custom_attribute("forum.backend.operation", "delete_thread") + set_custom_attribute("forum.thread_id", thread_id) + set_custom_attribute("forum.delete_mode", "soft") + if deleted_by: + set_custom_attribute("forum.deleted_by", deleted_by) + try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2268,6 +2299,17 @@ def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" + # Track thread creation + set_custom_attribute("forum.backend.operation", "create_thread") + set_custom_attribute("forum.course_id", data["course_id"]) + set_custom_attribute("forum.thread_type", data.get("thread_type", "discussion")) + set_custom_attribute( + "forum.commentable_id", data.get("commentable_id", "course") + ) + set_custom_attribute("forum.author_id", data["author_id"]) + if "group_id" in data: + set_custom_attribute("forum.group_id", str(data["group_id"])) + optional_args = {} if group_id := data.get("group_id"): optional_args["group_id"] = group_id @@ -2292,6 +2334,17 @@ def update_thread( **kwargs: Any, ) -> int: """Updates a thread document in the database.""" + # Track thread update + set_custom_attribute("forum.backend.operation", "update_thread") + set_custom_attribute("forum.thread_id", thread_id) + + # Track what's being updated + update_fields = [k for k in kwargs if kwargs.get(k) is not None] + if update_fields: + set_custom_attribute("forum.update_fields", ",".join(update_fields)) + if "course_id" in kwargs: + set_custom_attribute("forum.course_id", kwargs["course_id"]) + thread = CommentThread.objects.get(id=thread_id) if "thread_type" in kwargs: diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index 637cd629..e836498e 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -15,7 +15,7 @@ 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 model_utils.models import TimeStampedModel # pylint: disable=import-error from opaque_keys.edx.django.models import CourseKeyField from forum.utils import validate_upvote_or_downvote diff --git a/forum/migration_helpers.py b/forum/migration_helpers.py index 149ea030..fd8394b6 100644 --- a/forum/migration_helpers.py +++ b/forum/migration_helpers.py @@ -26,7 +26,6 @@ ) from forum.utils import make_aware, get_trunc_title - logger = logging.getLogger(__name__) diff --git a/forum/settings/common.py b/forum/settings/common.py index dbecd9cd..ab8d35c9 100644 --- a/forum/settings/common.py +++ b/forum/settings/common.py @@ -9,6 +9,9 @@ def plugin_settings(settings: Any) -> None: """ Common settings for forum app """ + # Configure Datadog monitoring + settings.OPENEDX_TELEMETRY = ["edx_django_utils.monitoring.DatadogBackend"] + # Search backend if getattr(settings, "MEILISEARCH_ENABLED", False): settings.FORUM_SEARCH_BACKEND = getattr( diff --git a/forum/views/comments.py b/forum/views/comments.py index 2dd4bf2b..046877a8 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -1,5 +1,6 @@ """Forum Comments API Views.""" +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -38,6 +39,9 @@ def get(self, request: Request, comment_id: str) -> Response: Response: The details of the comment for the given comment_id. """ + set_custom_attribute("forum.operation", "get_comment") + set_custom_attribute("forum.comment_id", comment_id) + try: data = get_parent_comment(comment_id) except ForumV2RequestError: @@ -65,8 +69,16 @@ def post(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is created. """ + set_custom_attribute("forum.operation", "create_child_comment") + set_custom_attribute("forum.parent_comment_id", comment_id) + + request_data = request.data + if "course_id" in request_data: + set_custom_attribute("forum.course_id", request_data["course_id"]) + if "user_id" in request_data: + set_custom_attribute("forum.author_id", request_data["user_id"]) + try: - request_data = request.data comment = create_child_comment( comment_id, request_data["body"], @@ -99,8 +111,20 @@ def put(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is updated. """ + set_custom_attribute("forum.operation", "update_comment") + set_custom_attribute("forum.comment_id", comment_id) + + # Track what fields are being updated + request_data = request.data + if request_data: + update_fields = [ + k for k in request_data.keys() if request_data.get(k) is not None + ] + set_custom_attribute("forum.update_fields", ",".join(update_fields)) + if "course_id" in request_data: + set_custom_attribute("forum.course_id", request_data["course_id"]) + try: - request_data = request.data if anonymous := request_data.get("anonymous"): anonymous = str_to_bool(anonymous) if anonymous_to_peers := request_data.get("anonymous_to_peers"): @@ -146,6 +170,9 @@ def delete(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is deleted. """ + set_custom_attribute("forum.operation", "delete_comment") + set_custom_attribute("forum.comment_id", comment_id) + try: deleted_comment = delete_comment(comment_id) except ForumV2RequestError: @@ -179,8 +206,16 @@ def post(self, request: Request, thread_id: str) -> Response: Response: The details of the comment that is created. """ + set_custom_attribute("forum.operation", "create_parent_comment") + set_custom_attribute("forum.thread_id", thread_id) + + request_data = request.data + if "course_id" in request_data: + set_custom_attribute("forum.course_id", request_data["course_id"]) + if "user_id" in request_data: + set_custom_attribute("forum.author_id", request_data["user_id"]) + try: - request_data = request.data comment = create_parent_comment( thread_id, request_data["body"], diff --git a/forum/views/threads.py b/forum/views/threads.py index 893bd2a3..7feb2d75 100644 --- a/forum/views/threads.py +++ b/forum/views/threads.py @@ -3,6 +3,7 @@ import logging from typing import Any +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -42,8 +43,22 @@ def get(self, request: Request, thread_id: str) -> Response: Returns: Response: A Response object containing the serialized thread data or an error message. """ + set_custom_attribute("forum.operation", "get_thread") + set_custom_attribute("forum.thread_id", thread_id) + try: params = request.query_params.dict() + + # Track request parameters + if "user_id" in params: + set_custom_attribute("forum.user_id", params["user_id"]) + if "with_responses" in params: + set_custom_attribute("forum.with_responses", params["with_responses"]) + if "resp_limit" in params: + set_custom_attribute("forum.resp_limit", params["resp_limit"]) + if "resp_skip" in params: + set_custom_attribute("forum.resp_skip", params["resp_skip"]) + data = get_thread(thread_id, params) except ForumV2RequestError as error: return Response( @@ -64,6 +79,9 @@ def delete(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is deleted. """ + set_custom_attribute("forum.operation", "delete_thread") + set_custom_attribute("forum.thread_id", thread_id) + try: serialized_data = delete_thread(thread_id) return Response(serialized_data, status=status.HTTP_200_OK) @@ -85,6 +103,15 @@ def put(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is updated. """ + set_custom_attribute("forum.operation", "update_thread") + set_custom_attribute("forum.thread_id", thread_id) + + # Track what fields are being updated + if request.data: + update_fields = list(request.data.keys()) + set_custom_attribute("forum.update_fields", ",".join(update_fields)) + if "course_id" in request.data: + set_custom_attribute("forum.course_id", request.data["course_id"]) try: serialized_data = update_thread(thread_id, **request.data) @@ -117,6 +144,19 @@ def post(self, request: Request) -> Response: Response: The details of the thread that is created. """ + set_custom_attribute("forum.operation", "create_thread") + + # Track thread creation context + if "course_id" in request.data: + set_custom_attribute("forum.course_id", request.data["course_id"]) + if "thread_type" in request.data: + set_custom_attribute("forum.thread_type", request.data["thread_type"]) + if "commentable_id" in request.data: + set_custom_attribute("forum.commentable_id", request.data["commentable_id"]) + if "user_id" in request.data: + set_custom_attribute("forum.author_id", request.data["user_id"]) + if "group_id" in request.data: + set_custom_attribute("forum.group_id", str(request.data["group_id"])) try: params = request.data diff --git a/forum/views/votes.py b/forum/views/votes.py index 2153c5cd..edc4c611 100644 --- a/forum/views/votes.py +++ b/forum/views/votes.py @@ -2,6 +2,7 @@ Vote Views """ +from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -52,6 +53,13 @@ def put(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ + set_custom_attribute("forum.operation", "vote_thread") + set_custom_attribute("forum.thread_id", thread_id) + if "user_id" in request.data: + set_custom_attribute("forum.user_id", request.data["user_id"]) + if "value" in request.data: + set_custom_attribute("forum.vote_type", request.data["value"]) + try: thread_response = update_thread_votes( thread_id, request.data["user_id"], request.data["value"] @@ -72,8 +80,13 @@ def delete(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ + set_custom_attribute("forum.operation", "remove_vote_thread") + set_custom_attribute("forum.thread_id", thread_id) + user_id = request.query_params.get("user_id", "") + if user_id: + set_custom_attribute("forum.user_id", user_id) + try: - user_id = request.query_params.get("user_id", "") thread_response = delete_thread_vote(thread_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -117,6 +130,13 @@ def put(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ + set_custom_attribute("forum.operation", "vote_comment") + set_custom_attribute("forum.comment_id", comment_id) + if "user_id" in request.data: + set_custom_attribute("forum.user_id", request.data["user_id"]) + if "value" in request.data: + set_custom_attribute("forum.vote_type", request.data["value"]) + try: comment_response = update_comment_votes( comment_id, request.data["user_id"], request.data["value"] @@ -137,8 +157,13 @@ def delete(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ + set_custom_attribute("forum.operation", "remove_vote_comment") + set_custom_attribute("forum.comment_id", comment_id) + user_id = request.query_params.get("user_id", "") + if user_id: + set_custom_attribute("forum.user_id", user_id) + try: - user_id = request.query_params.get("user_id", "") comment_response = delete_comment_vote(comment_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/requirements/base.in b/requirements/base.in index ab0c2866..64742ecc 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,3 +13,5 @@ pymongo elasticsearch edx-search # meilisearch backend mysqlclient +ddtrace +edx-django-utils diff --git a/requirements/base.txt b/requirements/base.txt index 449bdd19..11f87d12 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade @@ -8,27 +8,33 @@ amqp==5.3.1 # via kombu annotated-types==0.7.0 # via pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via httpx +asgiref==3.11.1 # via django -attrs==25.3.0 +attrs==26.1.0 # via openedx-events -beautifulsoup4==4.13.5 +beautifulsoup4==4.14.3 # via -r requirements/base.in -billiard==4.2.1 +billiard==4.2.4 # via celery -camel-converter[pydantic]==4.0.1 +bytecode==0.17.0 + # via ddtrace +camel-converter[pydantic]==5.1.0 # via meilisearch -celery==5.5.3 +celery==5.6.3 # via event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via pynacl -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via requests -click==8.2.1 +click==8.3.2 # via # celery # click-didyoumean @@ -42,13 +48,16 @@ click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via edx-toggles -django==4.2.23 +ddtrace==4.7.0 + # via -r requirements/base.in +django==5.2.13 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # django-crum + # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -60,84 +69,102 @@ django-crum==0.7.9 # via # edx-django-utils # edx-toggles +django-model-utils==5.0.0 + # via -r requirements/base.in django-waffle==5.0.0 # via # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via -r requirements/base.in -dnspython==2.7.0 +dnspython==2.8.0 # via pymongo edx-ccx-keys==2.0.2 # via openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via + # -r requirements/base.in # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via -r requirements/base.in -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via ddtrace +event-tracking==4.0.0 # via edx-search -fastavro==1.12.0 +fastavro==1.12.1 # via openedx-events -idna==3.10 - # via requests +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via typesense +idna==3.11 + # via + # anyio + # httpx + # requests +importlib-metadata==8.7.1 + # via opentelemetry-api jinja2==3.1.6 # via code-annotations -kombu==5.5.4 +kombu==5.6.2 # via celery -markupsafe==3.0.2 +markupsafe==3.0.3 # via jinja2 -meilisearch==0.37.0 +meilisearch==0.40.0 # via edx-search -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via -r requirements/base.in openedx-atlas==0.7.0 # via -r requirements/base.in openedx-events==10.5.0 # via event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via ddtrace +packaging==26.0 # via kombu prompt-toolkit==3.0.52 # via click-repl -psutil==7.0.0 +psutil==7.2.2 # via edx-django-utils -pycparser==2.22 +pycparser==3.0 # via cffi -pydantic==2.11.7 +pydantic==2.13.0 # via camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via pydantic -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/base.in # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via edx-django-utils python-dateutil==2.9.0.post0 # via celery python-slugify==8.0.4 # via code-annotations -pytz==2025.2 +pytz==2026.1.post1 # via event-tracking -pyyaml==6.0.2 +pyyaml==6.0.3 # via code-annotations -requests==2.32.5 +requests==2.33.1 # via # -r requirements/base.in # meilisearch @@ -146,28 +173,35 @@ six==1.17.0 # edx-ccx-keys # event-tracking # python-dateutil -soupsieve==2.8 +soupsieve==2.8.3 # via beautifulsoup4 -sqlparse==0.5.3 +sqlparse==0.5.5 # via django -stevedore==5.5.0 +stevedore==5.7.0 # via # code-annotations # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via python-slugify +typesense==2.0.0 + # via edx-search typing-extensions==4.15.0 # via + # anyio # beautifulsoup4 # edx-opaque-keys + # opentelemetry-api # pydantic # pydantic-core + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic -tzdata==2025.2 +tzdata==2026.1 # via kombu +tzlocal==5.3.1 + # via celery urllib3==1.26.20 # via # elasticsearch @@ -177,8 +211,12 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.2.13 +wcwidth==0.6.0 # via prompt-toolkit +wrapt==2.1.2 + # via ddtrace +zipp==3.23.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/ci.txt b/requirements/ci.txt index 748a1284..f18a1e82 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade @@ -12,60 +12,70 @@ annotated-types==0.7.0 # via # -r requirements/quality.txt # pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via + # -r requirements/quality.txt + # httpx +asgiref==3.11.1 # via # -r requirements/quality.txt # django -astroid==3.3.11 +astroid==4.0.4 # via # -r requirements/quality.txt # pylint # pylint-celery -attrs==25.3.0 +attrs==26.1.0 # via # -r requirements/quality.txt # openedx-events -beautifulsoup4==4.13.5 +backports-tarfile==1.2.0 + # via + # -r requirements/quality.txt + # jaraco-context +beautifulsoup4==4.14.3 # via -r requirements/quality.txt -billiard==4.2.1 +billiard==4.2.4 # via # -r requirements/quality.txt # celery -black==25.1.0 +black==26.3.1 # via -r requirements/ci.in -build==1.3.0 +build==1.4.3 # via -r requirements/quality.txt -cachetools==6.2.0 +bytecode==0.17.0 + # via + # -r requirements/quality.txt + # ddtrace +cachetools==7.0.5 # via # -r requirements/quality.txt # tox -camel-converter[pydantic]==4.0.1 +camel-converter[pydantic]==5.1.0 # via # -r requirements/quality.txt # meilisearch -celery==5.5.3 +celery==5.6.3 # via # -r requirements/quality.txt # event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # -r requirements/quality.txt # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via # -r requirements/quality.txt # cryptography # pynacl -chardet==5.2.0 - # via - # -r requirements/quality.txt - # tox -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via # -r requirements/quality.txt # requests -click==8.2.1 +click==8.3.2 # via # -r requirements/quality.txt # black @@ -93,7 +103,7 @@ click-repl==0.3.0 # via # -r requirements/quality.txt # celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via # -r requirements/quality.txt # edx-lint @@ -102,15 +112,17 @@ colorama==0.4.6 # via # -r requirements/quality.txt # tox -coverage[toml]==7.10.5 +coverage[toml]==7.13.5 # via # -r requirements/quality.txt # pytest-cov -cryptography==45.0.6 +cryptography==46.0.7 # via # -r requirements/quality.txt # secretstorage -dill==0.4.0 +ddtrace==4.7.0 + # via -r requirements/quality.txt +dill==0.4.1 # via # -r requirements/quality.txt # pylint @@ -118,11 +130,12 @@ distlib==0.4.0 # via # -r requirements/quality.txt # virtualenv -django==4.2.23 +django==5.2.13 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # django-crum + # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -137,11 +150,13 @@ django-crum==0.7.9 # -r requirements/quality.txt # edx-django-utils # edx-toggles -django-stubs==5.2.2 +django-model-utils==5.0.0 + # via -r requirements/quality.txt +django-stubs==6.0.2 # via # -r requirements/quality.txt # djangorestframework-stubs -django-stubs-ext==5.2.2 +django-stubs-ext==6.0.2 # via # -r requirements/quality.txt # django-stubs @@ -150,15 +165,15 @@ django-waffle==5.0.0 # -r requirements/quality.txt # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via -r requirements/quality.txt -djangorestframework-stubs==3.16.2 +djangorestframework-stubs==3.16.9 # via -r requirements/quality.txt -dnspython==2.7.0 +dnspython==2.8.0 # via # -r requirements/quality.txt # pymongo -docutils==0.22 +docutils==0.22.4 # via # -r requirements/quality.txt # readme-renderer @@ -166,59 +181,83 @@ edx-ccx-keys==2.0.2 # via # -r requirements/quality.txt # openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via # -r requirements/quality.txt # edx-toggles # event-tracking # openedx-events -edx-lint==5.6.0 +edx-lint==6.0.0 # via -r requirements/quality.txt -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/quality.txt # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via -r requirements/quality.txt -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # -r requirements/quality.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via + # -r requirements/quality.txt + # ddtrace +event-tracking==4.0.0 # via # -r requirements/quality.txt # edx-search -faker==37.6.0 +faker==40.13.0 # via -r requirements/quality.txt -fastavro==1.12.0 +fastavro==1.12.1 # via # -r requirements/quality.txt # openedx-events -filelock==3.19.1 +filelock==3.25.2 # via # -r requirements/quality.txt + # python-discovery # tox # virtualenv +h11==0.16.0 + # via + # -r requirements/quality.txt + # httpcore +httpcore==1.0.9 + # via + # -r requirements/quality.txt + # httpx +httpx==0.28.1 + # via + # -r requirements/quality.txt + # typesense id==1.5.0 # via # -r requirements/quality.txt # twine -idna==3.10 +idna==3.11 # via # -r requirements/quality.txt + # anyio + # httpx # requests -iniconfig==2.1.0 +importlib-metadata==8.7.1 + # via + # -r requirements/quality.txt + # keyring + # opentelemetry-api +iniconfig==2.3.0 # via # -r requirements/quality.txt # pytest -isort==6.0.1 +isort==8.0.1 # via # -r requirements/quality.txt # pylint @@ -226,11 +265,11 @@ jaraco-classes==3.4.0 # via # -r requirements/quality.txt # keyring -jaraco-context==6.0.1 +jaraco-context==6.1.2 # via # -r requirements/quality.txt # keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via # -r requirements/quality.txt # keyring @@ -243,19 +282,23 @@ jinja2==3.1.6 # via # -r requirements/quality.txt # code-annotations -keyring==25.6.0 +keyring==25.7.0 # via # -r requirements/quality.txt # twine -kombu==5.5.4 +kombu==5.6.2 # via # -r requirements/quality.txt # celery +librt==0.9.0 + # via + # -r requirements/quality.txt + # mypy markdown-it-py==4.0.0 # via # -r requirements/quality.txt # rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # -r requirements/quality.txt # jinja2 @@ -267,27 +310,27 @@ mdurl==0.1.2 # via # -r requirements/quality.txt # markdown-it-py -meilisearch==0.37.0 +meilisearch==0.40.0 # via # -r requirements/quality.txt # edx-search mongomock==4.3.0 # via -r requirements/quality.txt -more-itertools==10.7.0 +more-itertools==11.0.2 # via # -r requirements/quality.txt # jaraco-classes # jaraco-functools -mypy==1.17.1 +mypy==1.20.1 # via -r requirements/quality.txt mypy-extensions==1.1.0 # via # -r requirements/quality.txt # black # mypy -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via -r requirements/quality.txt -nh3==0.3.0 +nh3==0.3.4 # via # -r requirements/quality.txt # readme-renderer @@ -297,7 +340,11 @@ openedx-events==10.5.0 # via # -r requirements/quality.txt # event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via + # -r requirements/quality.txt + # ddtrace +packaging==26.0 # via # -r requirements/quality.txt # black @@ -308,16 +355,17 @@ packaging==25.0 # pytest # tox # twine -pathspec==0.12.1 +pathspec==1.0.4 # via # -r requirements/quality.txt # black # mypy -platformdirs==4.4.0 +platformdirs==4.9.6 # via # -r requirements/quality.txt # black # pylint + # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -330,33 +378,33 @@ prompt-toolkit==3.0.52 # via # -r requirements/quality.txt # click-repl -psutil==7.0.0 +psutil==7.2.2 # via # -r requirements/quality.txt # edx-django-utils pycodestyle==2.14.0 # via -r requirements/quality.txt -pycparser==2.22 +pycparser==3.0 # via # -r requirements/quality.txt # cffi -pydantic==2.11.7 +pydantic==2.13.0 # via # -r requirements/quality.txt # camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via # -r requirements/quality.txt # pydantic pydocstyle==6.3.0 # via -r requirements/quality.txt -pygments==2.19.2 +pygments==2.20.0 # via # -r requirements/quality.txt # pytest # readme-renderer # rich -pylint==3.3.8 +pylint==4.0.5 # via # -r requirements/quality.txt # edx-lint @@ -367,7 +415,7 @@ pylint-celery==0.3 # via # -r requirements/quality.txt # edx-lint -pylint-django==2.6.1 +pylint-django==2.7.0 # via # -r requirements/quality.txt # edx-lint @@ -376,16 +424,16 @@ pylint-plugin-utils==0.9.0 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/quality.txt # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via # -r requirements/quality.txt # edx-django-utils -pyproject-api==1.9.1 +pyproject-api==1.10.0 # via # -r requirements/quality.txt # tox @@ -393,29 +441,36 @@ pyproject-hooks==1.2.0 # via # -r requirements/quality.txt # build -pytest==8.4.1 +pytest==9.0.3 # via # -r requirements/quality.txt # pytest-cov # pytest-django -pytest-cov==6.2.1 +pytest-cov==7.1.0 # via -r requirements/quality.txt -pytest-django==4.11.1 +pytest-django==4.12.0 # via -r requirements/quality.txt python-dateutil==2.9.0.post0 # via # -r requirements/quality.txt # celery +python-discovery==1.2.2 + # via + # -r requirements/quality.txt + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/quality.txt # code-annotations -pytz==2025.2 +pytokens==0.4.1 + # via black +pytz==2026.1.post1 # via # -r requirements/quality.txt # event-tracking # mongomock -pyyaml==6.0.2 +pyyaml==6.0.3 # via # -r requirements/quality.txt # code-annotations @@ -423,10 +478,9 @@ readme-renderer==44.0 # via # -r requirements/quality.txt # twine -requests==2.32.5 +requests==2.33.1 # via # -r requirements/quality.txt - # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -439,11 +493,11 @@ rfc3986==2.0.0 # via # -r requirements/quality.txt # twine -rich==14.1.0 +rich==15.0.0 # via # -r requirements/quality.txt # twine -secretstorage==3.3.3 +secretstorage==3.5.0 # via # -r requirements/quality.txt # keyring @@ -462,15 +516,15 @@ snowballstemmer==3.0.1 # via # -r requirements/quality.txt # pydocstyle -soupsieve==2.8 +soupsieve==2.8.3 # via # -r requirements/quality.txt # beautifulsoup4 -sqlparse==0.5.3 +sqlparse==0.5.5 # via # -r requirements/quality.txt # django -stevedore==5.5.0 +stevedore==5.7.0 # via # -r requirements/quality.txt # code-annotations @@ -480,54 +534,70 @@ text-unidecode==1.3 # via # -r requirements/quality.txt # python-slugify -tomlkit==0.13.3 +tomli-w==1.2.0 + # via + # -r requirements/quality.txt + # tox +tomlkit==0.14.0 # via # -r requirements/quality.txt # pylint -tox==4.28.4 +tox==4.52.1 # via -r requirements/quality.txt -twine==6.1.0 +twine==6.2.0 # via -r requirements/quality.txt types-beautifulsoup4==4.12.0.20250516 # via -r requirements/quality.txt -types-html5lib==1.1.11.20250809 +types-html5lib==1.1.11.20260408 # via # -r requirements/quality.txt # types-beautifulsoup4 -types-pyyaml==6.0.12.20250822 +types-pyyaml==6.0.12.20260408 # via # -r requirements/quality.txt # django-stubs # djangorestframework-stubs types-requests==2.31.0.6 - # via - # -r requirements/quality.txt - # djangorestframework-stubs + # via -r requirements/quality.txt types-urllib3==1.26.25.14 # via # -r requirements/quality.txt # types-requests +types-webencodings==0.5.0.20260408 + # via + # -r requirements/quality.txt + # types-html5lib +typesense==2.0.0 + # via + # -r requirements/quality.txt + # edx-search typing-extensions==4.15.0 # via # -r requirements/quality.txt + # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy + # opentelemetry-api # pydantic # pydantic-core + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via # -r requirements/quality.txt # pydantic -tzdata==2025.2 +tzdata==2026.1 # via # -r requirements/quality.txt - # faker # kombu +tzlocal==5.3.1 + # via + # -r requirements/quality.txt + # celery urllib3==1.26.20 # via # -r requirements/quality.txt @@ -540,14 +610,22 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.34.0 +virtualenv==21.2.1 # via # -r requirements/quality.txt # tox -wcwidth==0.2.13 +wcwidth==0.6.0 # via # -r requirements/quality.txt # prompt-toolkit +wrapt==2.1.2 + # via + # -r requirements/quality.txt + # ddtrace +zipp==3.23.0 + # via + # -r requirements/quality.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/dev.txt b/requirements/dev.txt index 5e01b88a..d64137c7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade @@ -14,78 +14,91 @@ annotated-types==0.7.0 # -r requirements/ci.txt # -r requirements/quality.txt # pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # httpx +asgiref==3.11.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # django -astroid==3.3.11 +astroid==4.0.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # pylint # pylint-celery -attrs==25.3.0 +attrs==26.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -beautifulsoup4==4.13.5 +backports-tarfile==1.2.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # jaraco-context +beautifulsoup4==4.14.3 # via # -r requirements/ci.txt # -r requirements/quality.txt -billiard==4.2.1 +billiard==4.2.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # celery -black==25.1.0 +black==26.3.1 # via -r requirements/ci.txt -build==1.3.0 +build==1.4.3 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt # -r requirements/quality.txt # pip-tools -cachetools==6.2.0 +bytecode==0.17.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # ddtrace +cachetools==7.0.5 # via # -r requirements/ci.txt # -r requirements/quality.txt # tox -camel-converter[pydantic]==4.0.1 +camel-converter[pydantic]==5.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # meilisearch -celery==5.5.3 +celery==5.6.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # -r requirements/ci.txt # -r requirements/quality.txt # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # cryptography # pynacl -chardet==5.2.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # diff-cover - # tox -charset-normalizer==3.4.3 +chardet==7.4.2 + # via diff-cover +charset-normalizer==3.4.7 # via # -r requirements/ci.txt # -r requirements/quality.txt # requests -click==8.2.1 +click==8.3.2 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -120,7 +133,7 @@ click-repl==0.3.0 # -r requirements/ci.txt # -r requirements/quality.txt # celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -131,19 +144,23 @@ colorama==0.4.6 # -r requirements/ci.txt # -r requirements/quality.txt # tox -coverage[toml]==7.10.5 +coverage[toml]==7.13.5 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest-cov -cryptography==45.0.6 +cryptography==46.0.7 # via # -r requirements/ci.txt # -r requirements/quality.txt # secretstorage -diff-cover==9.6.0 +ddtrace==4.7.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt +diff-cover==10.2.0 # via -r requirements/dev.in -dill==0.4.0 +dill==0.4.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -153,12 +170,13 @@ distlib==0.4.0 # -r requirements/ci.txt # -r requirements/quality.txt # virtualenv -django==4.2.23 +django==5.2.13 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.txt # -r requirements/quality.txt # django-crum + # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -175,12 +193,16 @@ django-crum==0.7.9 # -r requirements/quality.txt # edx-django-utils # edx-toggles -django-stubs==5.2.2 +django-model-utils==5.0.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt +django-stubs==6.0.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # djangorestframework-stubs -django-stubs-ext==5.2.2 +django-stubs-ext==6.0.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -191,20 +213,20 @@ django-waffle==5.0.0 # -r requirements/quality.txt # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via # -r requirements/ci.txt # -r requirements/quality.txt -djangorestframework-stubs==3.16.2 +djangorestframework-stubs==3.16.9 # via # -r requirements/ci.txt # -r requirements/quality.txt -dnspython==2.7.0 +dnspython==2.8.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # pymongo -docutils==0.22 +docutils==0.22.4 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -214,30 +236,30 @@ edx-ccx-keys==2.0.2 # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-toggles # event-tracking # openedx-events -edx-i18n-tools==1.9.0 +edx-i18n-tools==2.0.0 # via -r requirements/dev.in -edx-lint==5.6.0 +edx-lint==6.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -245,46 +267,75 @@ edx-toggles==5.4.1 # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.txt # -r requirements/quality.txt # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # ddtrace +event-tracking==4.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-search -faker==37.6.0 +faker==40.13.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -fastavro==1.12.0 +fastavro==1.12.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -filelock==3.19.1 +filelock==3.25.2 # via # -r requirements/ci.txt # -r requirements/quality.txt + # python-discovery # tox # virtualenv +h11==0.16.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # httpcore +httpcore==1.0.9 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # httpx +httpx==0.28.1 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # typesense id==1.5.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -idna==3.10 +idna==3.11 # via # -r requirements/ci.txt # -r requirements/quality.txt + # anyio + # httpx # requests -iniconfig==2.1.0 +importlib-metadata==8.7.1 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # keyring + # opentelemetry-api +iniconfig==2.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest -isort==6.0.1 +isort==8.0.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -294,12 +345,12 @@ jaraco-classes==3.4.0 # -r requirements/ci.txt # -r requirements/quality.txt # keyring -jaraco-context==6.0.1 +jaraco-context==6.1.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -316,28 +367,33 @@ jinja2==3.1.6 # -r requirements/quality.txt # code-annotations # diff-cover -keyring==25.6.0 +keyring==25.7.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -kombu==5.5.4 +kombu==5.6.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # celery -lxml[html-clean]==6.0.1 +librt==0.9.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # mypy +lxml[html-clean]==6.0.4 # via # edx-i18n-tools # lxml-html-clean -lxml-html-clean==0.4.2 +lxml-html-clean==0.4.4 # via lxml markdown-it-py==4.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -352,7 +408,7 @@ mdurl==0.1.2 # -r requirements/ci.txt # -r requirements/quality.txt # markdown-it-py -meilisearch==0.37.0 +meilisearch==0.40.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -361,13 +417,13 @@ mongomock==4.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -more-itertools==10.7.0 +more-itertools==11.0.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # jaraco-classes # jaraco-functools -mypy==1.17.1 +mypy==1.20.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -377,11 +433,11 @@ mypy-extensions==1.1.0 # -r requirements/quality.txt # black # mypy -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via # -r requirements/ci.txt # -r requirements/quality.txt -nh3==0.3.0 +nh3==0.3.4 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -395,7 +451,12 @@ openedx-events==10.5.0 # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # ddtrace +packaging==26.0 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -408,22 +469,24 @@ packaging==25.0 # pytest # tox # twine + # wheel path==16.16.0 # via edx-i18n-tools -pathspec==0.12.1 +pathspec==1.0.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # black # mypy -pip-tools==7.5.0 +pip-tools==7.5.3 # via -r requirements/pip-tools.txt -platformdirs==4.4.0 +platformdirs==4.9.6 # via # -r requirements/ci.txt # -r requirements/quality.txt # black # pylint + # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -441,7 +504,7 @@ prompt-toolkit==3.0.52 # -r requirements/ci.txt # -r requirements/quality.txt # click-repl -psutil==7.0.0 +psutil==7.2.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -450,17 +513,17 @@ pycodestyle==2.14.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -pycparser==2.22 +pycparser==3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # cffi -pydantic==2.11.7 +pydantic==2.13.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -469,7 +532,7 @@ pydocstyle==6.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -pygments==2.19.2 +pygments==2.20.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -477,7 +540,7 @@ pygments==2.19.2 # pytest # readme-renderer # rich -pylint==3.3.8 +pylint==4.0.5 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -490,7 +553,7 @@ pylint-celery==0.3 # -r requirements/ci.txt # -r requirements/quality.txt # edx-lint -pylint-django==2.6.1 +pylint-django==2.7.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -501,18 +564,18 @@ pylint-plugin-utils==0.9.0 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-django-utils -pyproject-api==1.9.1 +pyproject-api==1.10.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -524,17 +587,17 @@ pyproject-hooks==1.2.0 # -r requirements/quality.txt # build # pip-tools -pytest==8.4.1 +pytest==9.0.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest-cov # pytest-django -pytest-cov==6.2.1 +pytest-cov==7.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -pytest-django==4.11.1 +pytest-django==4.12.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -543,18 +606,28 @@ python-dateutil==2.9.0.post0 # -r requirements/ci.txt # -r requirements/quality.txt # celery +python-discovery==1.2.2 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # code-annotations -pytz==2025.2 +pytokens==0.4.1 + # via + # -r requirements/ci.txt + # black +pytz==2026.1.post1 # via # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking # mongomock -pyyaml==6.0.2 +pyyaml==6.0.3 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -565,11 +638,10 @@ readme-renderer==44.0 # -r requirements/ci.txt # -r requirements/quality.txt # twine -requests==2.32.5 +requests==2.33.1 # via # -r requirements/ci.txt # -r requirements/quality.txt - # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -584,12 +656,12 @@ rfc3986==2.0.0 # -r requirements/ci.txt # -r requirements/quality.txt # twine -rich==14.1.0 +rich==15.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -secretstorage==3.3.3 +secretstorage==3.5.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -612,17 +684,17 @@ snowballstemmer==3.0.1 # -r requirements/ci.txt # -r requirements/quality.txt # pydocstyle -soupsieve==2.8 +soupsieve==2.8.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # beautifulsoup4 -sqlparse==0.5.3 +sqlparse==0.5.5 # via # -r requirements/ci.txt # -r requirements/quality.txt # django -stevedore==5.5.0 +stevedore==5.7.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -634,16 +706,21 @@ text-unidecode==1.3 # -r requirements/ci.txt # -r requirements/quality.txt # python-slugify -tomlkit==0.13.3 +tomli-w==1.2.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # tox +tomlkit==0.14.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # pylint -tox==4.28.4 +tox==4.52.1 # via # -r requirements/ci.txt # -r requirements/quality.txt -twine==6.1.0 +twine==6.2.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -651,12 +728,12 @@ types-beautifulsoup4==4.12.0.20250516 # via # -r requirements/ci.txt # -r requirements/quality.txt -types-html5lib==1.1.11.20250809 +types-html5lib==1.1.11.20260408 # via # -r requirements/ci.txt # -r requirements/quality.txt # types-beautifulsoup4 -types-pyyaml==6.0.12.20250822 +types-pyyaml==6.0.12.20260408 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -666,36 +743,52 @@ types-requests==2.31.0.6 # via # -r requirements/ci.txt # -r requirements/quality.txt - # djangorestframework-stubs types-urllib3==1.26.25.14 # via # -r requirements/ci.txt # -r requirements/quality.txt # types-requests +types-webencodings==0.5.0.20260408 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # types-html5lib +typesense==2.0.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # edx-search typing-extensions==4.15.0 # via # -r requirements/ci.txt # -r requirements/quality.txt + # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy + # opentelemetry-api # pydantic # pydantic-core + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # pydantic -tzdata==2025.2 +tzdata==2026.1 # via # -r requirements/ci.txt # -r requirements/quality.txt - # faker # kombu +tzlocal==5.3.1 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # celery urllib3==1.26.20 # via # -r requirements/ci.txt @@ -710,20 +803,30 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.34.0 +virtualenv==21.2.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # tox -wcwidth==0.2.13 +wcwidth==0.6.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # prompt-toolkit -wheel==0.45.1 +wheel==0.46.3 # via # -r requirements/pip-tools.txt # pip-tools +wrapt==2.1.2 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # ddtrace +zipp==3.23.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index e48ac9c0..c79f310c 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -1,10 +1,12 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade # -alabaster==0.7.16 +accessible-pygments==0.0.5 + # via pydata-sphinx-theme +alabaster==1.0.0 # via sphinx amqp==5.3.1 # via @@ -14,59 +16,69 @@ annotated-types==0.7.0 # via # -r requirements/test.txt # pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via + # -r requirements/test.txt + # httpx +asgiref==3.11.1 # via # -r requirements/test.txt # django -attrs==25.3.0 +attrs==26.1.0 # via # -r requirements/test.txt # openedx-events -babel==2.17.0 +babel==2.18.0 # via # pydata-sphinx-theme # sphinx -beautifulsoup4==4.13.5 +backports-tarfile==1.2.0 + # via + # -r requirements/test.txt + # jaraco-context +beautifulsoup4==4.14.3 # via # -r requirements/test.txt # pydata-sphinx-theme -billiard==4.2.1 +billiard==4.2.4 # via # -r requirements/test.txt # celery -build==1.3.0 +build==1.4.3 # via -r requirements/test.txt -cachetools==6.2.0 +bytecode==0.17.0 + # via + # -r requirements/test.txt + # ddtrace +cachetools==7.0.5 # via # -r requirements/test.txt # tox -camel-converter[pydantic]==4.0.1 +camel-converter[pydantic]==5.1.0 # via # -r requirements/test.txt # meilisearch -celery==5.5.3 +celery==5.6.3 # via # -r requirements/test.txt # event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # -r requirements/test.txt # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via # -r requirements/test.txt # cryptography # pynacl -chardet==5.2.0 - # via - # -r requirements/test.txt - # tox -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via # -r requirements/test.txt # requests -click==8.2.1 +click==8.3.2 # via # -r requirements/test.txt # celery @@ -87,7 +99,7 @@ click-repl==0.3.0 # via # -r requirements/test.txt # celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via # -r requirements/test.txt # edx-toggles @@ -95,23 +107,26 @@ colorama==0.4.6 # via # -r requirements/test.txt # tox -coverage[toml]==7.10.5 +coverage[toml]==7.13.5 # via # -r requirements/test.txt # pytest-cov -cryptography==45.0.6 +cryptography==46.0.7 # via # -r requirements/test.txt # secretstorage +ddtrace==4.7.0 + # via -r requirements/test.txt distlib==0.4.0 # via # -r requirements/test.txt # virtualenv -django==4.2.23 +django==5.2.13 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum + # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -124,20 +139,22 @@ django-crum==0.7.9 # -r requirements/test.txt # edx-django-utils # edx-toggles +django-model-utils==5.0.0 + # via -r requirements/test.txt django-waffle==5.0.0 # via # -r requirements/test.txt # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via -r requirements/test.txt -dnspython==2.7.0 +dnspython==2.8.0 # via # -r requirements/test.txt # pymongo doc8==0.11.2 # via -r requirements/doc.in -docutils==0.22 +docutils==0.22.4 # via # -r requirements/test.txt # doc8 @@ -149,55 +166,79 @@ edx-ccx-keys==2.0.2 # via # -r requirements/test.txt # openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via # -r requirements/test.txt # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/test.txt # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via -r requirements/test.txt -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # -r requirements/test.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via + # -r requirements/test.txt + # ddtrace +event-tracking==4.0.0 # via # -r requirements/test.txt # edx-search -faker==37.6.0 +faker==40.13.0 # via -r requirements/test.txt -fastavro==1.12.0 +fastavro==1.12.1 # via # -r requirements/test.txt # openedx-events -filelock==3.19.1 +filelock==3.25.2 # via # -r requirements/test.txt + # python-discovery # tox # virtualenv +h11==0.16.0 + # via + # -r requirements/test.txt + # httpcore +httpcore==1.0.9 + # via + # -r requirements/test.txt + # httpx +httpx==0.28.1 + # via + # -r requirements/test.txt + # typesense id==1.5.0 # via # -r requirements/test.txt # twine -idna==3.10 +idna==3.11 # via # -r requirements/test.txt + # anyio + # httpx # requests -imagesize==1.4.1 +imagesize==2.0.0 # via sphinx -iniconfig==2.1.0 +importlib-metadata==8.7.1 + # via + # -r requirements/test.txt + # keyring + # opentelemetry-api +iniconfig==2.3.0 # via # -r requirements/test.txt # pytest @@ -205,11 +246,11 @@ jaraco-classes==3.4.0 # via # -r requirements/test.txt # keyring -jaraco-context==6.0.1 +jaraco-context==6.1.2 # via # -r requirements/test.txt # keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via # -r requirements/test.txt # keyring @@ -223,11 +264,11 @@ jinja2==3.1.6 # -r requirements/test.txt # code-annotations # sphinx -keyring==25.6.0 +keyring==25.7.0 # via # -r requirements/test.txt # twine -kombu==5.5.4 +kombu==5.6.2 # via # -r requirements/test.txt # celery @@ -235,7 +276,7 @@ markdown-it-py==4.0.0 # via # -r requirements/test.txt # rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # -r requirements/test.txt # jinja2 @@ -243,20 +284,20 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -meilisearch==0.37.0 +meilisearch==0.40.0 # via # -r requirements/test.txt # edx-search mongomock==4.3.0 # via -r requirements/test.txt -more-itertools==10.7.0 +more-itertools==11.0.2 # via # -r requirements/test.txt # jaraco-classes # jaraco-functools -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via -r requirements/test.txt -nh3==0.3.0 +nh3==0.3.4 # via # -r requirements/test.txt # readme-renderer @@ -266,7 +307,11 @@ openedx-events==10.5.0 # via # -r requirements/test.txt # event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via + # -r requirements/test.txt + # ddtrace +packaging==26.0 # via # -r requirements/test.txt # build @@ -277,9 +322,10 @@ packaging==25.0 # sphinx # tox # twine -platformdirs==4.4.0 +platformdirs==4.9.6 # via # -r requirements/test.txt + # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -292,42 +338,44 @@ prompt-toolkit==3.0.52 # via # -r requirements/test.txt # click-repl -psutil==7.0.0 +psutil==7.2.2 # via # -r requirements/test.txt # edx-django-utils -pycparser==2.22 +pycparser==3.0 # via # -r requirements/test.txt # cffi -pydantic==2.11.7 +pydantic==2.13.0 # via # -r requirements/test.txt # camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via # -r requirements/test.txt # pydantic -pydata-sphinx-theme==0.8.0 +pydata-sphinx-theme==0.16.1 # via sphinx-book-theme -pygments==2.19.2 +pygments==2.20.0 # via # -r requirements/test.txt + # accessible-pygments # doc8 + # pydata-sphinx-theme # pytest # readme-renderer # rich # sphinx -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/test.txt # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via # -r requirements/test.txt # edx-django-utils -pyproject-api==1.9.1 +pyproject-api==1.10.0 # via # -r requirements/test.txt # tox @@ -335,38 +383,42 @@ pyproject-hooks==1.2.0 # via # -r requirements/test.txt # build -pytest==8.4.1 +pytest==9.0.3 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==6.2.1 +pytest-cov==7.1.0 # via -r requirements/test.txt -pytest-django==4.11.1 +pytest-django==4.12.0 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via # -r requirements/test.txt # celery +python-discovery==1.2.2 + # via + # -r requirements/test.txt + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2025.2 +pytz==2026.1.post1 # via # -r requirements/test.txt # event-tracking # mongomock -pyyaml==6.0.2 +pyyaml==6.0.3 # via # -r requirements/test.txt # code-annotations - # sphinx-book-theme readme-renderer==44.0 # via # -r requirements/test.txt # twine -requests==2.32.5 +requests==2.33.1 # via # -r requirements/test.txt # id @@ -378,17 +430,19 @@ requests-toolbelt==1.0.0 # via # -r requirements/test.txt # twine -restructuredtext-lint==1.4.0 +restructuredtext-lint==2.0.2 # via doc8 rfc3986==2.0.0 # via # -r requirements/test.txt # twine -rich==14.1.0 +rich==15.0.0 # via # -r requirements/test.txt # twine -secretstorage==3.3.3 +roman-numerals==4.1.0 + # via sphinx +secretstorage==3.5.0 # via # -r requirements/test.txt # keyring @@ -404,16 +458,16 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via sphinx -soupsieve==2.8 +soupsieve==2.8.3 # via # -r requirements/test.txt # beautifulsoup4 -sphinx==3.5.3 +sphinx==9.0.4 # via # -r requirements/doc.in # pydata-sphinx-theme # sphinx-book-theme -sphinx-book-theme==0.3.3 +sphinx-book-theme==1.2.0 # via -r requirements/doc.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -427,11 +481,11 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sqlparse==0.5.3 +sqlparse==0.5.5 # via # -r requirements/test.txt # django -stevedore==5.5.0 +stevedore==5.7.0 # via # -r requirements/test.txt # code-annotations @@ -442,27 +496,42 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tox==4.28.4 +tomli-w==1.2.0 + # via + # -r requirements/test.txt + # tox +tox==4.52.1 # via -r requirements/test.txt -twine==6.1.0 +twine==6.2.0 # via -r requirements/test.txt +typesense==2.0.0 + # via + # -r requirements/test.txt + # edx-search typing-extensions==4.15.0 # via # -r requirements/test.txt + # anyio # beautifulsoup4 # edx-opaque-keys + # opentelemetry-api # pydantic # pydantic-core + # pydata-sphinx-theme + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via # -r requirements/test.txt # pydantic -tzdata==2025.2 +tzdata==2026.1 # via # -r requirements/test.txt - # faker # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery urllib3==1.26.20 # via # -r requirements/test.txt @@ -475,14 +544,22 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.34.0 +virtualenv==21.2.1 # via # -r requirements/test.txt # tox -wcwidth==0.2.13 +wcwidth==0.6.0 # via # -r requirements/test.txt # prompt-toolkit +wrapt==2.1.2 + # via + # -r requirements/test.txt + # ddtrace +zipp==3.23.0 + # via + # -r requirements/test.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index bfdc0512..e00a0547 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,22 +1,24 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade # -build==1.3.0 +build==1.4.3 # via pip-tools -click==8.2.1 +click==8.3.2 # via pip-tools -packaging==25.0 - # via build -pip-tools==7.5.0 +packaging==26.0 + # via + # build + # wheel +pip-tools==7.5.3 # via -r requirements/pip-tools.in pyproject-hooks==1.2.0 # via # build # pip-tools -wheel==0.45.1 +wheel==0.46.3 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/pip.txt b/requirements/pip.txt index fca60af6..341a251b 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,16 +1,16 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade # -wheel==0.45.1 +packaging==26.0 + # via wheel +wheel==0.46.3 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==24.2 - # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt - # -r requirements/pip.in -setuptools==80.9.0 +pip==26.0.1 + # via -r requirements/pip.in +setuptools==82.0.1 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index fe0a2766..d6f8eb1a 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade @@ -12,57 +12,67 @@ annotated-types==0.7.0 # via # -r requirements/test.txt # pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via + # -r requirements/test.txt + # httpx +asgiref==3.11.1 # via # -r requirements/test.txt # django -astroid==3.3.11 +astroid==4.0.4 # via # pylint # pylint-celery -attrs==25.3.0 +attrs==26.1.0 # via # -r requirements/test.txt # openedx-events -beautifulsoup4==4.13.5 +backports-tarfile==1.2.0 + # via + # -r requirements/test.txt + # jaraco-context +beautifulsoup4==4.14.3 # via -r requirements/test.txt -billiard==4.2.1 +billiard==4.2.4 # via # -r requirements/test.txt # celery -build==1.3.0 +build==1.4.3 # via -r requirements/test.txt -cachetools==6.2.0 +bytecode==0.17.0 + # via + # -r requirements/test.txt + # ddtrace +cachetools==7.0.5 # via # -r requirements/test.txt # tox -camel-converter[pydantic]==4.0.1 +camel-converter[pydantic]==5.1.0 # via # -r requirements/test.txt # meilisearch -celery==5.5.3 +celery==5.6.3 # via # -r requirements/test.txt # event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # -r requirements/test.txt # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via # -r requirements/test.txt # cryptography # pynacl -chardet==5.2.0 - # via - # -r requirements/test.txt - # tox -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via # -r requirements/test.txt # requests -click==8.2.1 +click==8.3.2 # via # -r requirements/test.txt # celery @@ -87,7 +97,7 @@ click-repl==0.3.0 # via # -r requirements/test.txt # celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via # -r requirements/test.txt # edx-lint @@ -96,25 +106,28 @@ colorama==0.4.6 # via # -r requirements/test.txt # tox -coverage[toml]==7.10.5 +coverage[toml]==7.13.5 # via # -r requirements/test.txt # pytest-cov -cryptography==45.0.6 +cryptography==46.0.7 # via # -r requirements/test.txt # secretstorage -dill==0.4.0 +ddtrace==4.7.0 + # via -r requirements/test.txt +dill==0.4.1 # via pylint distlib==0.4.0 # via # -r requirements/test.txt # virtualenv -django==4.2.23 +django==5.2.13 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum + # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -129,24 +142,26 @@ django-crum==0.7.9 # -r requirements/test.txt # edx-django-utils # edx-toggles -django-stubs==5.2.2 +django-model-utils==5.0.0 + # via -r requirements/test.txt +django-stubs==6.0.2 # via djangorestframework-stubs -django-stubs-ext==5.2.2 +django-stubs-ext==6.0.2 # via django-stubs django-waffle==5.0.0 # via # -r requirements/test.txt # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via -r requirements/test.txt -djangorestframework-stubs==3.16.2 +djangorestframework-stubs==3.16.9 # via -r requirements/quality.in -dnspython==2.7.0 +dnspython==2.8.0 # via # -r requirements/test.txt # pymongo -docutils==0.22 +docutils==0.22.4 # via # -r requirements/test.txt # readme-renderer @@ -154,59 +169,83 @@ edx-ccx-keys==2.0.2 # via # -r requirements/test.txt # openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via # -r requirements/test.txt # edx-toggles # event-tracking # openedx-events -edx-lint==5.6.0 +edx-lint==6.0.0 # via -r requirements/quality.in -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/test.txt # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via -r requirements/test.txt -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # -r requirements/test.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via + # -r requirements/test.txt + # ddtrace +event-tracking==4.0.0 # via # -r requirements/test.txt # edx-search -faker==37.6.0 +faker==40.13.0 # via -r requirements/test.txt -fastavro==1.12.0 +fastavro==1.12.1 # via # -r requirements/test.txt # openedx-events -filelock==3.19.1 +filelock==3.25.2 # via # -r requirements/test.txt + # python-discovery # tox # virtualenv +h11==0.16.0 + # via + # -r requirements/test.txt + # httpcore +httpcore==1.0.9 + # via + # -r requirements/test.txt + # httpx +httpx==0.28.1 + # via + # -r requirements/test.txt + # typesense id==1.5.0 # via # -r requirements/test.txt # twine -idna==3.10 +idna==3.11 # via # -r requirements/test.txt + # anyio + # httpx # requests -iniconfig==2.1.0 +importlib-metadata==8.7.1 + # via + # -r requirements/test.txt + # keyring + # opentelemetry-api +iniconfig==2.3.0 # via # -r requirements/test.txt # pytest -isort==6.0.1 +isort==8.0.1 # via # -r requirements/quality.in # pylint @@ -214,11 +253,11 @@ jaraco-classes==3.4.0 # via # -r requirements/test.txt # keyring -jaraco-context==6.0.1 +jaraco-context==6.1.2 # via # -r requirements/test.txt # keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via # -r requirements/test.txt # keyring @@ -231,19 +270,21 @@ jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations -keyring==25.6.0 +keyring==25.7.0 # via # -r requirements/test.txt # twine -kombu==5.5.4 +kombu==5.6.2 # via # -r requirements/test.txt # celery +librt==0.9.0 + # via mypy markdown-it-py==4.0.0 # via # -r requirements/test.txt # rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # -r requirements/test.txt # jinja2 @@ -253,24 +294,24 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -meilisearch==0.37.0 +meilisearch==0.40.0 # via # -r requirements/test.txt # edx-search mongomock==4.3.0 # via -r requirements/test.txt -more-itertools==10.7.0 +more-itertools==11.0.2 # via # -r requirements/test.txt # jaraco-classes # jaraco-functools -mypy==1.17.1 +mypy==1.20.1 # via -r requirements/quality.in mypy-extensions==1.1.0 # via mypy -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via -r requirements/test.txt -nh3==0.3.0 +nh3==0.3.4 # via # -r requirements/test.txt # readme-renderer @@ -280,7 +321,11 @@ openedx-events==10.5.0 # via # -r requirements/test.txt # event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via + # -r requirements/test.txt + # ddtrace +packaging==26.0 # via # -r requirements/test.txt # build @@ -290,12 +335,13 @@ packaging==25.0 # pytest # tox # twine -pathspec==0.12.1 +pathspec==1.0.4 # via mypy -platformdirs==4.4.0 +platformdirs==4.9.6 # via # -r requirements/test.txt # pylint + # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -308,33 +354,33 @@ prompt-toolkit==3.0.52 # via # -r requirements/test.txt # click-repl -psutil==7.0.0 +psutil==7.2.2 # via # -r requirements/test.txt # edx-django-utils pycodestyle==2.14.0 # via -r requirements/quality.in -pycparser==2.22 +pycparser==3.0 # via # -r requirements/test.txt # cffi -pydantic==2.11.7 +pydantic==2.13.0 # via # -r requirements/test.txt # camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via # -r requirements/test.txt # pydantic pydocstyle==6.3.0 # via -r requirements/quality.in -pygments==2.19.2 +pygments==2.20.0 # via # -r requirements/test.txt # pytest # readme-renderer # rich -pylint==3.3.8 +pylint==4.0.5 # via # edx-lint # pylint-celery @@ -342,22 +388,22 @@ pylint==3.3.8 # pylint-plugin-utils pylint-celery==0.3 # via edx-lint -pylint-django==2.6.1 +pylint-django==2.7.0 # via edx-lint pylint-plugin-utils==0.9.0 # via # pylint-celery # pylint-django -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/test.txt # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via # -r requirements/test.txt # edx-django-utils -pyproject-api==1.9.1 +pyproject-api==1.10.0 # via # -r requirements/test.txt # tox @@ -365,29 +411,34 @@ pyproject-hooks==1.2.0 # via # -r requirements/test.txt # build -pytest==8.4.1 +pytest==9.0.3 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==6.2.1 +pytest-cov==7.1.0 # via -r requirements/test.txt -pytest-django==4.11.1 +pytest-django==4.12.0 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via # -r requirements/test.txt # celery +python-discovery==1.2.2 + # via + # -r requirements/test.txt + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2025.2 +pytz==2026.1.post1 # via # -r requirements/test.txt # event-tracking # mongomock -pyyaml==6.0.2 +pyyaml==6.0.3 # via # -r requirements/test.txt # code-annotations @@ -395,10 +446,9 @@ readme-renderer==44.0 # via # -r requirements/test.txt # twine -requests==2.32.5 +requests==2.33.1 # via # -r requirements/test.txt - # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -411,11 +461,11 @@ rfc3986==2.0.0 # via # -r requirements/test.txt # twine -rich==14.1.0 +rich==15.0.0 # via # -r requirements/test.txt # twine -secretstorage==3.3.3 +secretstorage==3.5.0 # via # -r requirements/test.txt # keyring @@ -432,15 +482,15 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via pydocstyle -soupsieve==2.8 +soupsieve==2.8.3 # via # -r requirements/test.txt # beautifulsoup4 -sqlparse==0.5.3 +sqlparse==0.5.5 # via # -r requirements/test.txt # django -stevedore==5.5.0 +stevedore==5.7.0 # via # -r requirements/test.txt # code-annotations @@ -450,47 +500,61 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomlkit==0.13.3 +tomli-w==1.2.0 + # via + # -r requirements/test.txt + # tox +tomlkit==0.14.0 # via pylint -tox==4.28.4 +tox==4.52.1 # via -r requirements/test.txt -twine==6.1.0 +twine==6.2.0 # via -r requirements/test.txt types-beautifulsoup4==4.12.0.20250516 # via -r requirements/quality.in -types-html5lib==1.1.11.20250809 +types-html5lib==1.1.11.20260408 # via types-beautifulsoup4 -types-pyyaml==6.0.12.20250822 +types-pyyaml==6.0.12.20260408 # via # django-stubs # djangorestframework-stubs types-requests==2.31.0.6 - # via - # -r requirements/quality.in - # djangorestframework-stubs + # via -r requirements/quality.in types-urllib3==1.26.25.14 # via types-requests +types-webencodings==0.5.0.20260408 + # via types-html5lib +typesense==2.0.0 + # via + # -r requirements/test.txt + # edx-search typing-extensions==4.15.0 # via # -r requirements/test.txt + # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy + # opentelemetry-api # pydantic # pydantic-core + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via # -r requirements/test.txt # pydantic -tzdata==2025.2 +tzdata==2026.1 # via # -r requirements/test.txt - # faker # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery urllib3==1.26.20 # via # -r requirements/test.txt @@ -503,14 +567,22 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.34.0 +virtualenv==21.2.1 # via # -r requirements/test.txt # tox -wcwidth==0.2.13 +wcwidth==0.6.0 # via # -r requirements/test.txt # prompt-toolkit +wrapt==2.1.2 + # via + # -r requirements/test.txt + # ddtrace +zipp==3.23.0 + # via + # -r requirements/test.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/test.txt b/requirements/test.txt index 87b4932d..cddd1c28 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # make upgrade @@ -12,49 +12,59 @@ annotated-types==0.7.0 # via # -r requirements/base.txt # pydantic -asgiref==3.9.1 +anyio==4.13.0 + # via + # -r requirements/base.txt + # httpx +asgiref==3.11.1 # via # -r requirements/base.txt # django -attrs==25.3.0 +attrs==26.1.0 # via # -r requirements/base.txt # openedx-events -beautifulsoup4==4.13.5 +backports-tarfile==1.2.0 + # via jaraco-context +beautifulsoup4==4.14.3 # via -r requirements/base.txt -billiard==4.2.1 +billiard==4.2.4 # via # -r requirements/base.txt # celery -build==1.3.0 +build==1.4.3 # via -r requirements/test.in -cachetools==6.2.0 +bytecode==0.17.0 + # via + # -r requirements/base.txt + # ddtrace +cachetools==7.0.5 # via tox -camel-converter[pydantic]==4.0.1 +camel-converter[pydantic]==5.1.0 # via # -r requirements/base.txt # meilisearch -celery==5.5.3 +celery==5.6.3 # via # -r requirements/base.txt # event-tracking -certifi==2025.8.3 +certifi==2026.2.25 # via # -r requirements/base.txt # elasticsearch + # httpcore + # httpx # requests -cffi==1.17.1 +cffi==2.0.0 # via # -r requirements/base.txt # cryptography # pynacl -chardet==5.2.0 - # via tox -charset-normalizer==3.4.3 +charset-normalizer==3.4.7 # via # -r requirements/base.txt # requests -click==8.2.1 +click==8.3.2 # via # -r requirements/base.txt # celery @@ -75,23 +85,26 @@ click-repl==0.3.0 # via # -r requirements/base.txt # celery -code-annotations==2.3.0 +code-annotations==2.3.2 # via # -r requirements/base.txt # -r requirements/test.in # edx-toggles colorama==0.4.6 # via tox -coverage[toml]==7.10.5 +coverage[toml]==7.13.5 # via pytest-cov -cryptography==45.0.6 +cryptography==46.0.7 # via secretstorage +ddtrace==4.7.0 + # via -r requirements/base.txt distlib==0.4.0 # via virtualenv # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # django-crum + # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -104,73 +117,99 @@ django-crum==0.7.9 # -r requirements/base.txt # edx-django-utils # edx-toggles +django-model-utils==5.0.0 + # via -r requirements/base.txt django-waffle==5.0.0 # via # -r requirements/base.txt # edx-django-utils # edx-toggles -djangorestframework==3.16.1 +djangorestframework==3.17.1 # via -r requirements/base.txt -dnspython==2.7.0 +dnspython==2.8.0 # via # -r requirements/base.txt # pymongo -docutils==0.22 +docutils==0.22.4 # via readme-renderer edx-ccx-keys==2.0.2 # via # -r requirements/base.txt # openedx-events -edx-django-utils==8.0.0 +edx-django-utils==8.0.1 # via # -r requirements/base.txt # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==3.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/base.txt # edx-ccx-keys # openedx-events -edx-search==4.1.3 +edx-search==5.0.0 # via -r requirements/base.txt -edx-toggles==5.4.1 +edx-toggles==6.0.0 # via # -r requirements/base.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # edx-search -event-tracking==3.3.0 +envier==0.6.1 + # via + # -r requirements/base.txt + # ddtrace +event-tracking==4.0.0 # via # -r requirements/base.txt # edx-search -faker==37.6.0 +faker==40.13.0 # via -r requirements/test.in -fastavro==1.12.0 +fastavro==1.12.1 # via # -r requirements/base.txt # openedx-events -filelock==3.19.1 +filelock==3.25.2 # via + # python-discovery # tox # virtualenv +h11==0.16.0 + # via + # -r requirements/base.txt + # httpcore +httpcore==1.0.9 + # via + # -r requirements/base.txt + # httpx +httpx==0.28.1 + # via + # -r requirements/base.txt + # typesense id==1.5.0 # via twine -idna==3.10 +idna==3.11 # via # -r requirements/base.txt + # anyio + # httpx # requests -iniconfig==2.1.0 +importlib-metadata==8.7.1 + # via + # -r requirements/base.txt + # keyring + # opentelemetry-api +iniconfig==2.3.0 # via pytest jaraco-classes==3.4.0 # via keyring -jaraco-context==6.0.1 +jaraco-context==6.1.2 # via keyring -jaraco-functools==4.3.0 +jaraco-functools==4.4.0 # via keyring jeepney==0.9.0 # via @@ -180,33 +219,33 @@ jinja2==3.1.6 # via # -r requirements/base.txt # code-annotations -keyring==25.6.0 +keyring==25.7.0 # via twine -kombu==5.5.4 +kombu==5.6.2 # via # -r requirements/base.txt # celery markdown-it-py==4.0.0 # via rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -meilisearch==0.37.0 +meilisearch==0.40.0 # via # -r requirements/base.txt # edx-search mongomock==4.3.0 # via -r requirements/test.in -more-itertools==10.7.0 +more-itertools==11.0.2 # via # jaraco-classes # jaraco-functools -mysqlclient==2.2.7 +mysqlclient==2.2.8 # via -r requirements/base.txt -nh3==0.3.0 +nh3==0.3.4 # via readme-renderer openedx-atlas==0.7.0 # via -r requirements/base.txt @@ -214,7 +253,11 @@ openedx-events==10.5.0 # via # -r requirements/base.txt # event-tracking -packaging==25.0 +opentelemetry-api==1.41.0 + # via + # -r requirements/base.txt + # ddtrace +packaging==26.0 # via # -r requirements/base.txt # build @@ -224,8 +267,9 @@ packaging==25.0 # pytest # tox # twine -platformdirs==4.4.0 +platformdirs==4.9.6 # via + # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -237,68 +281,72 @@ prompt-toolkit==3.0.52 # via # -r requirements/base.txt # click-repl -psutil==7.0.0 +psutil==7.2.2 # via # -r requirements/base.txt # edx-django-utils -pycparser==2.22 +pycparser==3.0 # via # -r requirements/base.txt # cffi -pydantic==2.11.7 +pydantic==2.13.0 # via # -r requirements/base.txt # camel-converter -pydantic-core==2.33.2 +pydantic-core==2.46.0 # via # -r requirements/base.txt # pydantic -pygments==2.19.2 +pygments==2.20.0 # via # pytest # readme-renderer # rich -pymongo==4.14.1 +pymongo==4.16.0 # via # -r requirements/base.txt # edx-opaque-keys # event-tracking -pynacl==1.5.0 +pynacl==1.6.2 # via # -r requirements/base.txt # edx-django-utils -pyproject-api==1.9.1 +pyproject-api==1.10.0 # via tox pyproject-hooks==1.2.0 # via build -pytest==8.4.1 +pytest==9.0.3 # via # pytest-cov # pytest-django -pytest-cov==6.2.1 +pytest-cov==7.1.0 # via -r requirements/test.in -pytest-django==4.11.1 +pytest-django==4.12.0 # via -r requirements/test.in python-dateutil==2.9.0.post0 # via # -r requirements/base.txt # celery +python-discovery==1.2.2 + # via + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/base.txt # code-annotations -pytz==2025.2 +pytz==2026.1.post1 # via # -r requirements/base.txt # event-tracking # mongomock -pyyaml==6.0.2 +pyyaml==6.0.3 # via # -r requirements/base.txt # code-annotations readme-renderer==44.0 # via twine -requests==2.32.5 +requests==2.33.1 # via # -r requirements/base.txt # id @@ -309,9 +357,9 @@ requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==14.1.0 +rich==15.0.0 # via twine -secretstorage==3.3.3 +secretstorage==3.5.0 # via keyring sentinels==1.1.1 # via mongomock @@ -321,15 +369,15 @@ six==1.17.0 # edx-ccx-keys # event-tracking # python-dateutil -soupsieve==2.8 +soupsieve==2.8.3 # via # -r requirements/base.txt # beautifulsoup4 -sqlparse==0.5.3 +sqlparse==0.5.5 # via # -r requirements/base.txt # django -stevedore==5.5.0 +stevedore==5.7.0 # via # -r requirements/base.txt # code-annotations @@ -339,27 +387,39 @@ text-unidecode==1.3 # via # -r requirements/base.txt # python-slugify -tox==4.28.4 +tomli-w==1.2.0 + # via tox +tox==4.52.1 # via -r requirements/test.in -twine==6.1.0 +twine==6.2.0 # via -r requirements/test.in +typesense==2.0.0 + # via + # -r requirements/base.txt + # edx-search typing-extensions==4.15.0 # via # -r requirements/base.txt + # anyio # beautifulsoup4 # edx-opaque-keys + # opentelemetry-api # pydantic # pydantic-core + # typesense # typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via # -r requirements/base.txt # pydantic -tzdata==2025.2 +tzdata==2026.1 # via # -r requirements/base.txt - # faker # kombu +tzlocal==5.3.1 + # via + # -r requirements/base.txt + # celery urllib3==1.26.20 # via # -r requirements/base.txt @@ -372,12 +432,20 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.34.0 +virtualenv==21.2.1 # via tox -wcwidth==0.2.13 +wcwidth==0.6.0 # via # -r requirements/base.txt # prompt-toolkit +wrapt==2.1.2 + # via + # -r requirements/base.txt + # ddtrace +zipp==3.23.0 + # via + # -r requirements/base.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index 19c52af7..6eef312c 100755 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ """ Package metadata for forum. """ + import os import re import sys diff --git a/tests/test_backends/test_mongodb/test_comments.py b/tests/test_backends/test_mongodb/test_comments.py index 4b255054..05831775 100644 --- a/tests/test_backends/test_mongodb/test_comments.py +++ b/tests/test_backends/test_mongodb/test_comments.py @@ -2,6 +2,7 @@ """ Tests for the `Comment` model. """ + from forum.backends.mongodb import Comment diff --git a/tests/test_management/test_commands/test_migration_commands.py b/tests/test_management/test_commands/test_migration_commands.py index 06965897..c6ac9281 100644 --- a/tests/test_management/test_commands/test_migration_commands.py +++ b/tests/test_management/test_commands/test_migration_commands.py @@ -23,7 +23,6 @@ ) from forum.utils import get_trunc_title - pytestmark = pytest.mark.django_db From f328d11908bd0ce22744191191410ec70c8133be Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Tue, 14 Apr 2026 14:47:19 +0530 Subject: [PATCH 17/22] feat: update init file (#30) --- forum/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum/__init__.py b/forum/__init__.py index 9111a804..fa16b91e 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.9" +__version__ = "0.5.0" From 5eb6f5be3e2327a70cfb2d95487b55fdbe2cb466 Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Tue, 14 Apr 2026 19:19:27 +0530 Subject: [PATCH 18/22] Revert "feat: add instrumentation to forum (#29)" This reverts commit 45352228ce3f1595b1f5e44e2d1937afbd57af93. --- forum/backend.py | 12 +- forum/backends/mongodb/comments.py | 20 -- forum/backends/mongodb/threads.py | 13 - forum/backends/mysql/api.py | 53 ---- forum/backends/mysql/models.py | 2 +- forum/migration_helpers.py | 1 + forum/settings/common.py | 3 - forum/views/comments.py | 41 +-- forum/views/threads.py | 40 --- forum/views/votes.py | 29 +- requirements/base.in | 2 - requirements/base.txt | 128 +++----- requirements/ci.txt | 262 ++++++--------- requirements/dev.txt | 299 ++++++------------ requirements/doc.txt | 239 +++++--------- requirements/pip-tools.txt | 16 +- requirements/pip.txt | 14 +- requirements/quality.txt | 254 ++++++--------- requirements/test.txt | 208 ++++-------- setup.py | 1 - .../test_mongodb/test_comments.py | 1 - .../test_commands/test_migration_commands.py | 1 + 22 files changed, 500 insertions(+), 1139 deletions(-) diff --git a/forum/backend.py b/forum/backend.py index b939503e..194f5113 100644 --- a/forum/backend.py +++ b/forum/backend.py @@ -2,8 +2,6 @@ from typing import Callable, Optional -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] - from forum.backends.mongodb.api import MongoBackend from forum.backends.mysql.api import MySQLBackend @@ -39,15 +37,7 @@ def get_backend( """Return a factory function that lazily loads the backend API based on course_id.""" def _get_backend() -> MongoBackend | MySQLBackend: - backend_enabled = is_mysql_backend_enabled(course_id) - - # Track which backend is being used - backend_type = "mysql" if backend_enabled else "mongodb" - set_custom_attribute("forum.backend", backend_type) - if course_id: - set_custom_attribute("forum.backend.course_id", str(course_id)) - - if backend_enabled: + if is_mysql_backend_enabled(course_id): return MySQLBackend() return MongoBackend() diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 32d313b8..527fc0b0 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -4,7 +4,6 @@ from typing import Any, Optional from bson import ObjectId -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.threads import CommentThread @@ -103,18 +102,6 @@ def insert( Returns: str: The ID of the inserted document. """ - # Track comment insertion - set_custom_attribute("forum.backend.operation", "insert_comment") - set_custom_attribute("forum.thread_id", comment_thread_id) - set_custom_attribute("forum.course_id", course_id) - set_custom_attribute("forum.author_id", author_id) - set_custom_attribute("forum.comment_depth", str(depth)) - if parent_id: - set_custom_attribute("forum.parent_comment_id", parent_id) - set_custom_attribute("forum.is_child_comment", True) - else: - set_custom_attribute("forum.is_child_comment", False) - date = datetime.now() comment_data = { "votes": self.get_votes_dict(up=[], down=[]), @@ -288,13 +275,6 @@ def delete( # type: ignore[override] Returns: The number of comments deleted. """ - # Track comment deletion - set_custom_attribute("forum.backend.operation", "delete_comment") - set_custom_attribute("forum.comment_id", _id) - set_custom_attribute("forum.delete_mode", mode) - if deleted_by: - set_custom_attribute("forum.deleted_by", deleted_by) - comment = self.get(_id) if not comment: return 0, 0 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 3e392113..9b5da85f 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -4,7 +4,6 @@ from typing import Any, Optional from bson import ObjectId -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.users import Users @@ -21,9 +20,6 @@ class CommentThread(BaseContents): def delete(self, _id: str) -> int: """Delete CommentThread""" - set_custom_attribute("forum.backend.operation", "delete_thread") - set_custom_attribute("forum.thread_id", _id) - result = super().delete(_id) get_handler_by_name("comment_thread_deleted").send( sender=self.__class__, comment_thread_id=_id @@ -146,15 +142,6 @@ def insert( if historical_abuse_flaggers is None: historical_abuse_flaggers = [] - # Track thread insertion - set_custom_attribute("forum.backend.operation", "insert_thread") - set_custom_attribute("forum.thread_type", thread_type) - set_custom_attribute("forum.course_id", course_id) - set_custom_attribute("forum.commentable_id", commentable_id) - set_custom_attribute("forum.author_id", author_id) - if group_id: - set_custom_attribute("forum.group_id", str(group_id)) - date = datetime.now() thread_data = { "votes": self.get_votes_dict(up=[], down=[]), diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index ee161a2c..63102e44 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -24,7 +24,6 @@ When, ) from django.utils import timezone -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.response import Response @@ -1687,10 +1686,6 @@ def create_comment(cls, data: dict[str, Any]) -> str: @classmethod def delete_comment(cls, comment_id: str) -> None: """Delete comment from comment_id.""" - set_custom_attribute("forum.backend.operation", "delete_comment") - set_custom_attribute("forum.comment_id", comment_id) - set_custom_attribute("forum.delete_mode", "hard") - comment = Comment.objects.get(pk=comment_id) if comment.parent: cls.update_child_count_in_parent_comment(str(comment.parent.pk), -1) @@ -1706,12 +1701,6 @@ def soft_delete_comment( Returns: tuple: (responses_deleted, replies_deleted) """ - set_custom_attribute("forum.backend.operation", "delete_comment") - set_custom_attribute("forum.comment_id", comment_id) - set_custom_attribute("forum.delete_mode", "soft") - if deleted_by: - set_custom_attribute("forum.deleted_by", deleted_by) - comment = Comment.objects.get(pk=comment_id) deleted_user: Optional[User] = None if deleted_by: @@ -2055,17 +2044,6 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: @staticmethod def update_comment(comment_id: str, **kwargs: Any) -> int: """Updates a comment in the database.""" - # Track comment update - set_custom_attribute("forum.backend.operation", "update_comment") - set_custom_attribute("forum.comment_id", comment_id) - - # Track what's being updated - update_fields = [k for k in kwargs if kwargs.get(k) is not None] - if update_fields: - set_custom_attribute("forum.update_fields", ",".join(update_fields)) - if "course_id" in kwargs: - set_custom_attribute("forum.course_id", kwargs["course_id"]) - try: comment = Comment.objects.get(id=comment_id) except Comment.DoesNotExist: @@ -2266,9 +2244,6 @@ def get_subscriptions(cls, query: dict[str, Any]) -> list[dict[str, Any]]: @staticmethod def delete_thread(thread_id: str) -> int: """Delete thread from thread_id.""" - set_custom_attribute("forum.backend.operation", "delete_thread") - set_custom_attribute("forum.thread_id", thread_id) - try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2279,12 +2254,6 @@ def delete_thread(thread_id: str) -> int: @staticmethod def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: """Soft delete thread by marking it as deleted.""" - set_custom_attribute("forum.backend.operation", "delete_thread") - set_custom_attribute("forum.thread_id", thread_id) - set_custom_attribute("forum.delete_mode", "soft") - if deleted_by: - set_custom_attribute("forum.deleted_by", deleted_by) - try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2299,17 +2268,6 @@ def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" - # Track thread creation - set_custom_attribute("forum.backend.operation", "create_thread") - set_custom_attribute("forum.course_id", data["course_id"]) - set_custom_attribute("forum.thread_type", data.get("thread_type", "discussion")) - set_custom_attribute( - "forum.commentable_id", data.get("commentable_id", "course") - ) - set_custom_attribute("forum.author_id", data["author_id"]) - if "group_id" in data: - set_custom_attribute("forum.group_id", str(data["group_id"])) - optional_args = {} if group_id := data.get("group_id"): optional_args["group_id"] = group_id @@ -2334,17 +2292,6 @@ def update_thread( **kwargs: Any, ) -> int: """Updates a thread document in the database.""" - # Track thread update - set_custom_attribute("forum.backend.operation", "update_thread") - set_custom_attribute("forum.thread_id", thread_id) - - # Track what's being updated - update_fields = [k for k in kwargs if kwargs.get(k) is not None] - if update_fields: - set_custom_attribute("forum.update_fields", ",".join(update_fields)) - if "course_id" in kwargs: - set_custom_attribute("forum.course_id", kwargs["course_id"]) - thread = CommentThread.objects.get(id=thread_id) if "thread_type" in kwargs: diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index e836498e..637cd629 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -15,7 +15,7 @@ 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 # pylint: disable=import-error +from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField from forum.utils import validate_upvote_or_downvote diff --git a/forum/migration_helpers.py b/forum/migration_helpers.py index fd8394b6..149ea030 100644 --- a/forum/migration_helpers.py +++ b/forum/migration_helpers.py @@ -26,6 +26,7 @@ ) from forum.utils import make_aware, get_trunc_title + logger = logging.getLogger(__name__) diff --git a/forum/settings/common.py b/forum/settings/common.py index ab8d35c9..dbecd9cd 100644 --- a/forum/settings/common.py +++ b/forum/settings/common.py @@ -9,9 +9,6 @@ def plugin_settings(settings: Any) -> None: """ Common settings for forum app """ - # Configure Datadog monitoring - settings.OPENEDX_TELEMETRY = ["edx_django_utils.monitoring.DatadogBackend"] - # Search backend if getattr(settings, "MEILISEARCH_ENABLED", False): settings.FORUM_SEARCH_BACKEND = getattr( diff --git a/forum/views/comments.py b/forum/views/comments.py index 046877a8..2dd4bf2b 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -1,6 +1,5 @@ """Forum Comments API Views.""" -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -39,9 +38,6 @@ def get(self, request: Request, comment_id: str) -> Response: Response: The details of the comment for the given comment_id. """ - set_custom_attribute("forum.operation", "get_comment") - set_custom_attribute("forum.comment_id", comment_id) - try: data = get_parent_comment(comment_id) except ForumV2RequestError: @@ -69,16 +65,8 @@ def post(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is created. """ - set_custom_attribute("forum.operation", "create_child_comment") - set_custom_attribute("forum.parent_comment_id", comment_id) - - request_data = request.data - if "course_id" in request_data: - set_custom_attribute("forum.course_id", request_data["course_id"]) - if "user_id" in request_data: - set_custom_attribute("forum.author_id", request_data["user_id"]) - try: + request_data = request.data comment = create_child_comment( comment_id, request_data["body"], @@ -111,20 +99,8 @@ def put(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is updated. """ - set_custom_attribute("forum.operation", "update_comment") - set_custom_attribute("forum.comment_id", comment_id) - - # Track what fields are being updated - request_data = request.data - if request_data: - update_fields = [ - k for k in request_data.keys() if request_data.get(k) is not None - ] - set_custom_attribute("forum.update_fields", ",".join(update_fields)) - if "course_id" in request_data: - set_custom_attribute("forum.course_id", request_data["course_id"]) - try: + request_data = request.data if anonymous := request_data.get("anonymous"): anonymous = str_to_bool(anonymous) if anonymous_to_peers := request_data.get("anonymous_to_peers"): @@ -170,9 +146,6 @@ def delete(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is deleted. """ - set_custom_attribute("forum.operation", "delete_comment") - set_custom_attribute("forum.comment_id", comment_id) - try: deleted_comment = delete_comment(comment_id) except ForumV2RequestError: @@ -206,16 +179,8 @@ def post(self, request: Request, thread_id: str) -> Response: Response: The details of the comment that is created. """ - set_custom_attribute("forum.operation", "create_parent_comment") - set_custom_attribute("forum.thread_id", thread_id) - - request_data = request.data - if "course_id" in request_data: - set_custom_attribute("forum.course_id", request_data["course_id"]) - if "user_id" in request_data: - set_custom_attribute("forum.author_id", request_data["user_id"]) - try: + request_data = request.data comment = create_parent_comment( thread_id, request_data["body"], diff --git a/forum/views/threads.py b/forum/views/threads.py index 7feb2d75..893bd2a3 100644 --- a/forum/views/threads.py +++ b/forum/views/threads.py @@ -3,7 +3,6 @@ import logging from typing import Any -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -43,22 +42,8 @@ def get(self, request: Request, thread_id: str) -> Response: Returns: Response: A Response object containing the serialized thread data or an error message. """ - set_custom_attribute("forum.operation", "get_thread") - set_custom_attribute("forum.thread_id", thread_id) - try: params = request.query_params.dict() - - # Track request parameters - if "user_id" in params: - set_custom_attribute("forum.user_id", params["user_id"]) - if "with_responses" in params: - set_custom_attribute("forum.with_responses", params["with_responses"]) - if "resp_limit" in params: - set_custom_attribute("forum.resp_limit", params["resp_limit"]) - if "resp_skip" in params: - set_custom_attribute("forum.resp_skip", params["resp_skip"]) - data = get_thread(thread_id, params) except ForumV2RequestError as error: return Response( @@ -79,9 +64,6 @@ def delete(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is deleted. """ - set_custom_attribute("forum.operation", "delete_thread") - set_custom_attribute("forum.thread_id", thread_id) - try: serialized_data = delete_thread(thread_id) return Response(serialized_data, status=status.HTTP_200_OK) @@ -103,15 +85,6 @@ def put(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is updated. """ - set_custom_attribute("forum.operation", "update_thread") - set_custom_attribute("forum.thread_id", thread_id) - - # Track what fields are being updated - if request.data: - update_fields = list(request.data.keys()) - set_custom_attribute("forum.update_fields", ",".join(update_fields)) - if "course_id" in request.data: - set_custom_attribute("forum.course_id", request.data["course_id"]) try: serialized_data = update_thread(thread_id, **request.data) @@ -144,19 +117,6 @@ def post(self, request: Request) -> Response: Response: The details of the thread that is created. """ - set_custom_attribute("forum.operation", "create_thread") - - # Track thread creation context - if "course_id" in request.data: - set_custom_attribute("forum.course_id", request.data["course_id"]) - if "thread_type" in request.data: - set_custom_attribute("forum.thread_type", request.data["thread_type"]) - if "commentable_id" in request.data: - set_custom_attribute("forum.commentable_id", request.data["commentable_id"]) - if "user_id" in request.data: - set_custom_attribute("forum.author_id", request.data["user_id"]) - if "group_id" in request.data: - set_custom_attribute("forum.group_id", str(request.data["group_id"])) try: params = request.data diff --git a/forum/views/votes.py b/forum/views/votes.py index edc4c611..2153c5cd 100644 --- a/forum/views/votes.py +++ b/forum/views/votes.py @@ -2,7 +2,6 @@ Vote Views """ -from edx_django_utils.monitoring import set_custom_attribute # type: ignore[import-untyped] from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -53,13 +52,6 @@ def put(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ - set_custom_attribute("forum.operation", "vote_thread") - set_custom_attribute("forum.thread_id", thread_id) - if "user_id" in request.data: - set_custom_attribute("forum.user_id", request.data["user_id"]) - if "value" in request.data: - set_custom_attribute("forum.vote_type", request.data["value"]) - try: thread_response = update_thread_votes( thread_id, request.data["user_id"], request.data["value"] @@ -80,13 +72,8 @@ def delete(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ - set_custom_attribute("forum.operation", "remove_vote_thread") - set_custom_attribute("forum.thread_id", thread_id) - user_id = request.query_params.get("user_id", "") - if user_id: - set_custom_attribute("forum.user_id", user_id) - try: + user_id = request.query_params.get("user_id", "") thread_response = delete_thread_vote(thread_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -130,13 +117,6 @@ def put(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ - set_custom_attribute("forum.operation", "vote_comment") - set_custom_attribute("forum.comment_id", comment_id) - if "user_id" in request.data: - set_custom_attribute("forum.user_id", request.data["user_id"]) - if "value" in request.data: - set_custom_attribute("forum.vote_type", request.data["value"]) - try: comment_response = update_comment_votes( comment_id, request.data["user_id"], request.data["value"] @@ -157,13 +137,8 @@ def delete(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ - set_custom_attribute("forum.operation", "remove_vote_comment") - set_custom_attribute("forum.comment_id", comment_id) - user_id = request.query_params.get("user_id", "") - if user_id: - set_custom_attribute("forum.user_id", user_id) - try: + user_id = request.query_params.get("user_id", "") comment_response = delete_comment_vote(comment_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/requirements/base.in b/requirements/base.in index 64742ecc..ab0c2866 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,5 +13,3 @@ pymongo elasticsearch edx-search # meilisearch backend mysqlclient -ddtrace -edx-django-utils diff --git a/requirements/base.txt b/requirements/base.txt index 11f87d12..449bdd19 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade @@ -8,33 +8,27 @@ amqp==5.3.1 # via kombu annotated-types==0.7.0 # via pydantic -anyio==4.13.0 - # via httpx -asgiref==3.11.1 +asgiref==3.9.1 # via django -attrs==26.1.0 +attrs==25.3.0 # via openedx-events -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via -r requirements/base.in -billiard==4.2.4 +billiard==4.2.1 # via celery -bytecode==0.17.0 - # via ddtrace -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via meilisearch -celery==5.6.3 +celery==5.5.3 # via event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via pynacl -charset-normalizer==3.4.7 +charset-normalizer==3.4.3 # via requests -click==8.3.2 +click==8.2.1 # via # celery # click-didyoumean @@ -48,16 +42,13 @@ click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via edx-toggles -ddtrace==4.7.0 - # via -r requirements/base.in -django==5.2.13 +django==4.2.23 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # django-crum - # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -69,102 +60,84 @@ django-crum==0.7.9 # via # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via -r requirements/base.in django-waffle==5.0.0 # via # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via -r requirements/base.in -dnspython==2.8.0 +dnspython==2.7.0 # via pymongo edx-ccx-keys==2.0.2 # via openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via - # -r requirements/base.in # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via -r requirements/base.in -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # edx-search -envier==0.6.1 - # via ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via edx-search -fastavro==1.12.1 +fastavro==1.12.0 # via openedx-events -h11==0.16.0 - # via httpcore -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via typesense -idna==3.11 - # via - # anyio - # httpx - # requests -importlib-metadata==8.7.1 - # via opentelemetry-api +idna==3.10 + # via requests jinja2==3.1.6 # via code-annotations -kombu==5.6.2 +kombu==5.5.4 # via celery -markupsafe==3.0.3 +markupsafe==3.0.2 # via jinja2 -meilisearch==0.40.0 +meilisearch==0.37.0 # via edx-search -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via -r requirements/base.in openedx-atlas==0.7.0 # via -r requirements/base.in openedx-events==10.5.0 # via event-tracking -opentelemetry-api==1.41.0 - # via ddtrace -packaging==26.0 +packaging==25.0 # via kombu prompt-toolkit==3.0.52 # via click-repl -psutil==7.2.2 +psutil==7.0.0 # via edx-django-utils -pycparser==3.0 +pycparser==2.22 # via cffi -pydantic==2.13.0 +pydantic==2.11.7 # via camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via pydantic -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/base.in # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via edx-django-utils python-dateutil==2.9.0.post0 # via celery python-slugify==8.0.4 # via code-annotations -pytz==2026.1.post1 +pytz==2025.2 # via event-tracking -pyyaml==6.0.3 +pyyaml==6.0.2 # via code-annotations -requests==2.33.1 +requests==2.32.5 # via # -r requirements/base.in # meilisearch @@ -173,35 +146,28 @@ six==1.17.0 # edx-ccx-keys # event-tracking # python-dateutil -soupsieve==2.8.3 +soupsieve==2.8 # via beautifulsoup4 -sqlparse==0.5.5 +sqlparse==0.5.3 # via django -stevedore==5.7.0 +stevedore==5.5.0 # via # code-annotations # edx-django-utils # edx-opaque-keys text-unidecode==1.3 # via python-slugify -typesense==2.0.0 - # via edx-search typing-extensions==4.15.0 # via - # anyio # beautifulsoup4 # edx-opaque-keys - # opentelemetry-api # pydantic # pydantic-core - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via pydantic -tzdata==2026.1 +tzdata==2025.2 # via kombu -tzlocal==5.3.1 - # via celery urllib3==1.26.20 # via # elasticsearch @@ -211,12 +177,8 @@ vine==5.1.0 # amqp # celery # kombu -wcwidth==0.6.0 +wcwidth==0.2.13 # via prompt-toolkit -wrapt==2.1.2 - # via ddtrace -zipp==3.23.0 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/ci.txt b/requirements/ci.txt index f18a1e82..748a1284 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade @@ -12,70 +12,60 @@ annotated-types==0.7.0 # via # -r requirements/quality.txt # pydantic -anyio==4.13.0 - # via - # -r requirements/quality.txt - # httpx -asgiref==3.11.1 +asgiref==3.9.1 # via # -r requirements/quality.txt # django -astroid==4.0.4 +astroid==3.3.11 # via # -r requirements/quality.txt # pylint # pylint-celery -attrs==26.1.0 +attrs==25.3.0 # via # -r requirements/quality.txt # openedx-events -backports-tarfile==1.2.0 - # via - # -r requirements/quality.txt - # jaraco-context -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via -r requirements/quality.txt -billiard==4.2.4 +billiard==4.2.1 # via # -r requirements/quality.txt # celery -black==26.3.1 +black==25.1.0 # via -r requirements/ci.in -build==1.4.3 +build==1.3.0 # via -r requirements/quality.txt -bytecode==0.17.0 - # via - # -r requirements/quality.txt - # ddtrace -cachetools==7.0.5 +cachetools==6.2.0 # via # -r requirements/quality.txt # tox -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via # -r requirements/quality.txt # meilisearch -celery==5.6.3 +celery==5.5.3 # via # -r requirements/quality.txt # event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # -r requirements/quality.txt # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via # -r requirements/quality.txt # cryptography # pynacl -charset-normalizer==3.4.7 +chardet==5.2.0 + # via + # -r requirements/quality.txt + # tox +charset-normalizer==3.4.3 # via # -r requirements/quality.txt # requests -click==8.3.2 +click==8.2.1 # via # -r requirements/quality.txt # black @@ -103,7 +93,7 @@ click-repl==0.3.0 # via # -r requirements/quality.txt # celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via # -r requirements/quality.txt # edx-lint @@ -112,17 +102,15 @@ colorama==0.4.6 # via # -r requirements/quality.txt # tox -coverage[toml]==7.13.5 +coverage[toml]==7.10.5 # via # -r requirements/quality.txt # pytest-cov -cryptography==46.0.7 +cryptography==45.0.6 # via # -r requirements/quality.txt # secretstorage -ddtrace==4.7.0 - # via -r requirements/quality.txt -dill==0.4.1 +dill==0.4.0 # via # -r requirements/quality.txt # pylint @@ -130,12 +118,11 @@ distlib==0.4.0 # via # -r requirements/quality.txt # virtualenv -django==5.2.13 +django==4.2.23 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # django-crum - # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -150,13 +137,11 @@ django-crum==0.7.9 # -r requirements/quality.txt # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via -r requirements/quality.txt -django-stubs==6.0.2 +django-stubs==5.2.2 # via # -r requirements/quality.txt # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==5.2.2 # via # -r requirements/quality.txt # django-stubs @@ -165,15 +150,15 @@ django-waffle==5.0.0 # -r requirements/quality.txt # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via -r requirements/quality.txt -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.2 # via -r requirements/quality.txt -dnspython==2.8.0 +dnspython==2.7.0 # via # -r requirements/quality.txt # pymongo -docutils==0.22.4 +docutils==0.22 # via # -r requirements/quality.txt # readme-renderer @@ -181,83 +166,59 @@ edx-ccx-keys==2.0.2 # via # -r requirements/quality.txt # openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via # -r requirements/quality.txt # edx-toggles # event-tracking # openedx-events -edx-lint==6.0.0 +edx-lint==5.6.0 # via -r requirements/quality.txt -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/quality.txt # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via -r requirements/quality.txt -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # -r requirements/quality.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # edx-search -envier==0.6.1 - # via - # -r requirements/quality.txt - # ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via # -r requirements/quality.txt # edx-search -faker==40.13.0 +faker==37.6.0 # via -r requirements/quality.txt -fastavro==1.12.1 +fastavro==1.12.0 # via # -r requirements/quality.txt # openedx-events -filelock==3.25.2 +filelock==3.19.1 # via # -r requirements/quality.txt - # python-discovery # tox # virtualenv -h11==0.16.0 - # via - # -r requirements/quality.txt - # httpcore -httpcore==1.0.9 - # via - # -r requirements/quality.txt - # httpx -httpx==0.28.1 - # via - # -r requirements/quality.txt - # typesense id==1.5.0 # via # -r requirements/quality.txt # twine -idna==3.11 +idna==3.10 # via # -r requirements/quality.txt - # anyio - # httpx # requests -importlib-metadata==8.7.1 - # via - # -r requirements/quality.txt - # keyring - # opentelemetry-api -iniconfig==2.3.0 +iniconfig==2.1.0 # via # -r requirements/quality.txt # pytest -isort==8.0.1 +isort==6.0.1 # via # -r requirements/quality.txt # pylint @@ -265,11 +226,11 @@ jaraco-classes==3.4.0 # via # -r requirements/quality.txt # keyring -jaraco-context==6.1.2 +jaraco-context==6.0.1 # via # -r requirements/quality.txt # keyring -jaraco-functools==4.4.0 +jaraco-functools==4.3.0 # via # -r requirements/quality.txt # keyring @@ -282,23 +243,19 @@ jinja2==3.1.6 # via # -r requirements/quality.txt # code-annotations -keyring==25.7.0 +keyring==25.6.0 # via # -r requirements/quality.txt # twine -kombu==5.6.2 +kombu==5.5.4 # via # -r requirements/quality.txt # celery -librt==0.9.0 - # via - # -r requirements/quality.txt - # mypy markdown-it-py==4.0.0 # via # -r requirements/quality.txt # rich -markupsafe==3.0.3 +markupsafe==3.0.2 # via # -r requirements/quality.txt # jinja2 @@ -310,27 +267,27 @@ mdurl==0.1.2 # via # -r requirements/quality.txt # markdown-it-py -meilisearch==0.40.0 +meilisearch==0.37.0 # via # -r requirements/quality.txt # edx-search mongomock==4.3.0 # via -r requirements/quality.txt -more-itertools==11.0.2 +more-itertools==10.7.0 # via # -r requirements/quality.txt # jaraco-classes # jaraco-functools -mypy==1.20.1 +mypy==1.17.1 # via -r requirements/quality.txt mypy-extensions==1.1.0 # via # -r requirements/quality.txt # black # mypy -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via -r requirements/quality.txt -nh3==0.3.4 +nh3==0.3.0 # via # -r requirements/quality.txt # readme-renderer @@ -340,11 +297,7 @@ openedx-events==10.5.0 # via # -r requirements/quality.txt # event-tracking -opentelemetry-api==1.41.0 - # via - # -r requirements/quality.txt - # ddtrace -packaging==26.0 +packaging==25.0 # via # -r requirements/quality.txt # black @@ -355,17 +308,16 @@ packaging==26.0 # pytest # tox # twine -pathspec==1.0.4 +pathspec==0.12.1 # via # -r requirements/quality.txt # black # mypy -platformdirs==4.9.6 +platformdirs==4.4.0 # via # -r requirements/quality.txt # black # pylint - # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -378,33 +330,33 @@ prompt-toolkit==3.0.52 # via # -r requirements/quality.txt # click-repl -psutil==7.2.2 +psutil==7.0.0 # via # -r requirements/quality.txt # edx-django-utils pycodestyle==2.14.0 # via -r requirements/quality.txt -pycparser==3.0 +pycparser==2.22 # via # -r requirements/quality.txt # cffi -pydantic==2.13.0 +pydantic==2.11.7 # via # -r requirements/quality.txt # camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via # -r requirements/quality.txt # pydantic pydocstyle==6.3.0 # via -r requirements/quality.txt -pygments==2.20.0 +pygments==2.19.2 # via # -r requirements/quality.txt # pytest # readme-renderer # rich -pylint==4.0.5 +pylint==3.3.8 # via # -r requirements/quality.txt # edx-lint @@ -415,7 +367,7 @@ pylint-celery==0.3 # via # -r requirements/quality.txt # edx-lint -pylint-django==2.7.0 +pylint-django==2.6.1 # via # -r requirements/quality.txt # edx-lint @@ -424,16 +376,16 @@ pylint-plugin-utils==0.9.0 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/quality.txt # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via # -r requirements/quality.txt # edx-django-utils -pyproject-api==1.10.0 +pyproject-api==1.9.1 # via # -r requirements/quality.txt # tox @@ -441,36 +393,29 @@ pyproject-hooks==1.2.0 # via # -r requirements/quality.txt # build -pytest==9.0.3 +pytest==8.4.1 # via # -r requirements/quality.txt # pytest-cov # pytest-django -pytest-cov==7.1.0 +pytest-cov==6.2.1 # via -r requirements/quality.txt -pytest-django==4.12.0 +pytest-django==4.11.1 # via -r requirements/quality.txt python-dateutil==2.9.0.post0 # via # -r requirements/quality.txt # celery -python-discovery==1.2.2 - # via - # -r requirements/quality.txt - # tox - # virtualenv python-slugify==8.0.4 # via # -r requirements/quality.txt # code-annotations -pytokens==0.4.1 - # via black -pytz==2026.1.post1 +pytz==2025.2 # via # -r requirements/quality.txt # event-tracking # mongomock -pyyaml==6.0.3 +pyyaml==6.0.2 # via # -r requirements/quality.txt # code-annotations @@ -478,9 +423,10 @@ readme-renderer==44.0 # via # -r requirements/quality.txt # twine -requests==2.33.1 +requests==2.32.5 # via # -r requirements/quality.txt + # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -493,11 +439,11 @@ rfc3986==2.0.0 # via # -r requirements/quality.txt # twine -rich==15.0.0 +rich==14.1.0 # via # -r requirements/quality.txt # twine -secretstorage==3.5.0 +secretstorage==3.3.3 # via # -r requirements/quality.txt # keyring @@ -516,15 +462,15 @@ snowballstemmer==3.0.1 # via # -r requirements/quality.txt # pydocstyle -soupsieve==2.8.3 +soupsieve==2.8 # via # -r requirements/quality.txt # beautifulsoup4 -sqlparse==0.5.5 +sqlparse==0.5.3 # via # -r requirements/quality.txt # django -stevedore==5.7.0 +stevedore==5.5.0 # via # -r requirements/quality.txt # code-annotations @@ -534,70 +480,54 @@ text-unidecode==1.3 # via # -r requirements/quality.txt # python-slugify -tomli-w==1.2.0 - # via - # -r requirements/quality.txt - # tox -tomlkit==0.14.0 +tomlkit==0.13.3 # via # -r requirements/quality.txt # pylint -tox==4.52.1 +tox==4.28.4 # via -r requirements/quality.txt -twine==6.2.0 +twine==6.1.0 # via -r requirements/quality.txt types-beautifulsoup4==4.12.0.20250516 # via -r requirements/quality.txt -types-html5lib==1.1.11.20260408 +types-html5lib==1.1.11.20250809 # via # -r requirements/quality.txt # types-beautifulsoup4 -types-pyyaml==6.0.12.20260408 +types-pyyaml==6.0.12.20250822 # via # -r requirements/quality.txt # django-stubs # djangorestframework-stubs types-requests==2.31.0.6 - # via -r requirements/quality.txt -types-urllib3==1.26.25.14 # via # -r requirements/quality.txt - # types-requests -types-webencodings==0.5.0.20260408 - # via - # -r requirements/quality.txt - # types-html5lib -typesense==2.0.0 + # djangorestframework-stubs +types-urllib3==1.26.25.14 # via # -r requirements/quality.txt - # edx-search + # types-requests typing-extensions==4.15.0 # via # -r requirements/quality.txt - # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy - # opentelemetry-api # pydantic # pydantic-core - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via # -r requirements/quality.txt # pydantic -tzdata==2026.1 +tzdata==2025.2 # via # -r requirements/quality.txt + # faker # kombu -tzlocal==5.3.1 - # via - # -r requirements/quality.txt - # celery urllib3==1.26.20 # via # -r requirements/quality.txt @@ -610,22 +540,14 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.2.1 +virtualenv==20.34.0 # via # -r requirements/quality.txt # tox -wcwidth==0.6.0 +wcwidth==0.2.13 # via # -r requirements/quality.txt # prompt-toolkit -wrapt==2.1.2 - # via - # -r requirements/quality.txt - # ddtrace -zipp==3.23.0 - # via - # -r requirements/quality.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/dev.txt b/requirements/dev.txt index d64137c7..5e01b88a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade @@ -14,91 +14,78 @@ annotated-types==0.7.0 # -r requirements/ci.txt # -r requirements/quality.txt # pydantic -anyio==4.13.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # httpx -asgiref==3.11.1 +asgiref==3.9.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # django -astroid==4.0.4 +astroid==3.3.11 # via # -r requirements/ci.txt # -r requirements/quality.txt # pylint # pylint-celery -attrs==26.1.0 +attrs==25.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -backports-tarfile==1.2.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # jaraco-context -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via # -r requirements/ci.txt # -r requirements/quality.txt -billiard==4.2.4 +billiard==4.2.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # celery -black==26.3.1 +black==25.1.0 # via -r requirements/ci.txt -build==1.4.3 +build==1.3.0 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt # -r requirements/quality.txt # pip-tools -bytecode==0.17.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # ddtrace -cachetools==7.0.5 +cachetools==6.2.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # tox -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # meilisearch -celery==5.6.3 +celery==5.5.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # cryptography # pynacl -chardet==7.4.2 - # via diff-cover -charset-normalizer==3.4.7 +chardet==5.2.0 + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # diff-cover + # tox +charset-normalizer==3.4.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # requests -click==8.3.2 +click==8.2.1 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -133,7 +120,7 @@ click-repl==0.3.0 # -r requirements/ci.txt # -r requirements/quality.txt # celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -144,23 +131,19 @@ colorama==0.4.6 # -r requirements/ci.txt # -r requirements/quality.txt # tox -coverage[toml]==7.13.5 +coverage[toml]==7.10.5 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest-cov -cryptography==46.0.7 +cryptography==45.0.6 # via # -r requirements/ci.txt # -r requirements/quality.txt # secretstorage -ddtrace==4.7.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt -diff-cover==10.2.0 +diff-cover==9.6.0 # via -r requirements/dev.in -dill==0.4.1 +dill==0.4.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -170,13 +153,12 @@ distlib==0.4.0 # -r requirements/ci.txt # -r requirements/quality.txt # virtualenv -django==5.2.13 +django==4.2.23 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.txt # -r requirements/quality.txt # django-crum - # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -193,16 +175,12 @@ django-crum==0.7.9 # -r requirements/quality.txt # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt -django-stubs==6.0.2 +django-stubs==5.2.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==5.2.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -213,20 +191,20 @@ django-waffle==5.0.0 # -r requirements/quality.txt # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via # -r requirements/ci.txt # -r requirements/quality.txt -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.2 # via # -r requirements/ci.txt # -r requirements/quality.txt -dnspython==2.8.0 +dnspython==2.7.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # pymongo -docutils==0.22.4 +docutils==0.22 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -236,30 +214,30 @@ edx-ccx-keys==2.0.2 # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-toggles # event-tracking # openedx-events -edx-i18n-tools==2.0.0 +edx-i18n-tools==1.9.0 # via -r requirements/dev.in -edx-lint==6.0.0 +edx-lint==5.6.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via # -r requirements/ci.txt # -r requirements/quality.txt -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -267,75 +245,46 @@ edx-toggles==6.0.0 # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/ci.txt # -r requirements/quality.txt # edx-search -envier==0.6.1 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-search -faker==40.13.0 +faker==37.6.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -fastavro==1.12.1 +fastavro==1.12.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # openedx-events -filelock==3.25.2 +filelock==3.19.1 # via # -r requirements/ci.txt # -r requirements/quality.txt - # python-discovery # tox # virtualenv -h11==0.16.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # httpcore -httpcore==1.0.9 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # httpx -httpx==0.28.1 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # typesense id==1.5.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -idna==3.11 +idna==3.10 # via # -r requirements/ci.txt # -r requirements/quality.txt - # anyio - # httpx # requests -importlib-metadata==8.7.1 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # keyring - # opentelemetry-api -iniconfig==2.3.0 +iniconfig==2.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest -isort==8.0.1 +isort==6.0.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -345,12 +294,12 @@ jaraco-classes==3.4.0 # -r requirements/ci.txt # -r requirements/quality.txt # keyring -jaraco-context==6.1.2 +jaraco-context==6.0.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # keyring -jaraco-functools==4.4.0 +jaraco-functools==4.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -367,33 +316,28 @@ jinja2==3.1.6 # -r requirements/quality.txt # code-annotations # diff-cover -keyring==25.7.0 +keyring==25.6.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -kombu==5.6.2 +kombu==5.5.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # celery -librt==0.9.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # mypy -lxml[html-clean]==6.0.4 +lxml[html-clean]==6.0.1 # via # edx-i18n-tools # lxml-html-clean -lxml-html-clean==0.4.4 +lxml-html-clean==0.4.2 # via lxml markdown-it-py==4.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # rich -markupsafe==3.0.3 +markupsafe==3.0.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -408,7 +352,7 @@ mdurl==0.1.2 # -r requirements/ci.txt # -r requirements/quality.txt # markdown-it-py -meilisearch==0.40.0 +meilisearch==0.37.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -417,13 +361,13 @@ mongomock==4.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -more-itertools==11.0.2 +more-itertools==10.7.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # jaraco-classes # jaraco-functools -mypy==1.20.1 +mypy==1.17.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -433,11 +377,11 @@ mypy-extensions==1.1.0 # -r requirements/quality.txt # black # mypy -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via # -r requirements/ci.txt # -r requirements/quality.txt -nh3==0.3.4 +nh3==0.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -451,12 +395,7 @@ openedx-events==10.5.0 # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking -opentelemetry-api==1.41.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # ddtrace -packaging==26.0 +packaging==25.0 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -469,24 +408,22 @@ packaging==26.0 # pytest # tox # twine - # wheel path==16.16.0 # via edx-i18n-tools -pathspec==1.0.4 +pathspec==0.12.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # black # mypy -pip-tools==7.5.3 +pip-tools==7.5.0 # via -r requirements/pip-tools.txt -platformdirs==4.9.6 +platformdirs==4.4.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # black # pylint - # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -504,7 +441,7 @@ prompt-toolkit==3.0.52 # -r requirements/ci.txt # -r requirements/quality.txt # click-repl -psutil==7.2.2 +psutil==7.0.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -513,17 +450,17 @@ pycodestyle==2.14.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -pycparser==3.0 +pycparser==2.22 # via # -r requirements/ci.txt # -r requirements/quality.txt # cffi -pydantic==2.13.0 +pydantic==2.11.7 # via # -r requirements/ci.txt # -r requirements/quality.txt # camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -532,7 +469,7 @@ pydocstyle==6.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt -pygments==2.20.0 +pygments==2.19.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -540,7 +477,7 @@ pygments==2.20.0 # pytest # readme-renderer # rich -pylint==4.0.5 +pylint==3.3.8 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -553,7 +490,7 @@ pylint-celery==0.3 # -r requirements/ci.txt # -r requirements/quality.txt # edx-lint -pylint-django==2.7.0 +pylint-django==2.6.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -564,18 +501,18 @@ pylint-plugin-utils==0.9.0 # -r requirements/quality.txt # pylint-celery # pylint-django -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # edx-django-utils -pyproject-api==1.10.0 +pyproject-api==1.9.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -587,17 +524,17 @@ pyproject-hooks==1.2.0 # -r requirements/quality.txt # build # pip-tools -pytest==9.0.3 +pytest==8.4.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # pytest-cov # pytest-django -pytest-cov==7.1.0 +pytest-cov==6.2.1 # via # -r requirements/ci.txt # -r requirements/quality.txt -pytest-django==4.12.0 +pytest-django==4.11.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -606,28 +543,18 @@ python-dateutil==2.9.0.post0 # -r requirements/ci.txt # -r requirements/quality.txt # celery -python-discovery==1.2.2 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # tox - # virtualenv python-slugify==8.0.4 # via # -r requirements/ci.txt # -r requirements/quality.txt # code-annotations -pytokens==0.4.1 - # via - # -r requirements/ci.txt - # black -pytz==2026.1.post1 +pytz==2025.2 # via # -r requirements/ci.txt # -r requirements/quality.txt # event-tracking # mongomock -pyyaml==6.0.3 +pyyaml==6.0.2 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -638,10 +565,11 @@ readme-renderer==44.0 # -r requirements/ci.txt # -r requirements/quality.txt # twine -requests==2.33.1 +requests==2.32.5 # via # -r requirements/ci.txt # -r requirements/quality.txt + # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -656,12 +584,12 @@ rfc3986==2.0.0 # -r requirements/ci.txt # -r requirements/quality.txt # twine -rich==15.0.0 +rich==14.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # twine -secretstorage==3.5.0 +secretstorage==3.3.3 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -684,17 +612,17 @@ snowballstemmer==3.0.1 # -r requirements/ci.txt # -r requirements/quality.txt # pydocstyle -soupsieve==2.8.3 +soupsieve==2.8 # via # -r requirements/ci.txt # -r requirements/quality.txt # beautifulsoup4 -sqlparse==0.5.5 +sqlparse==0.5.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # django -stevedore==5.7.0 +stevedore==5.5.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -706,21 +634,16 @@ text-unidecode==1.3 # -r requirements/ci.txt # -r requirements/quality.txt # python-slugify -tomli-w==1.2.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # tox -tomlkit==0.14.0 +tomlkit==0.13.3 # via # -r requirements/ci.txt # -r requirements/quality.txt # pylint -tox==4.52.1 +tox==4.28.4 # via # -r requirements/ci.txt # -r requirements/quality.txt -twine==6.2.0 +twine==6.1.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -728,12 +651,12 @@ types-beautifulsoup4==4.12.0.20250516 # via # -r requirements/ci.txt # -r requirements/quality.txt -types-html5lib==1.1.11.20260408 +types-html5lib==1.1.11.20250809 # via # -r requirements/ci.txt # -r requirements/quality.txt # types-beautifulsoup4 -types-pyyaml==6.0.12.20260408 +types-pyyaml==6.0.12.20250822 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -743,52 +666,36 @@ types-requests==2.31.0.6 # via # -r requirements/ci.txt # -r requirements/quality.txt + # djangorestframework-stubs types-urllib3==1.26.25.14 # via # -r requirements/ci.txt # -r requirements/quality.txt # types-requests -types-webencodings==0.5.0.20260408 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # types-html5lib -typesense==2.0.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # edx-search typing-extensions==4.15.0 # via # -r requirements/ci.txt # -r requirements/quality.txt - # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy - # opentelemetry-api # pydantic # pydantic-core - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via # -r requirements/ci.txt # -r requirements/quality.txt # pydantic -tzdata==2026.1 +tzdata==2025.2 # via # -r requirements/ci.txt # -r requirements/quality.txt + # faker # kombu -tzlocal==5.3.1 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # celery urllib3==1.26.20 # via # -r requirements/ci.txt @@ -803,30 +710,20 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.2.1 +virtualenv==20.34.0 # via # -r requirements/ci.txt # -r requirements/quality.txt # tox -wcwidth==0.6.0 +wcwidth==0.2.13 # via # -r requirements/ci.txt # -r requirements/quality.txt # prompt-toolkit -wheel==0.46.3 +wheel==0.45.1 # via # -r requirements/pip-tools.txt # pip-tools -wrapt==2.1.2 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # ddtrace -zipp==3.23.0 - # via - # -r requirements/ci.txt - # -r requirements/quality.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index c79f310c..e48ac9c0 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -1,12 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade # -accessible-pygments==0.0.5 - # via pydata-sphinx-theme -alabaster==1.0.0 +alabaster==0.7.16 # via sphinx amqp==5.3.1 # via @@ -16,69 +14,59 @@ annotated-types==0.7.0 # via # -r requirements/test.txt # pydantic -anyio==4.13.0 - # via - # -r requirements/test.txt - # httpx -asgiref==3.11.1 +asgiref==3.9.1 # via # -r requirements/test.txt # django -attrs==26.1.0 +attrs==25.3.0 # via # -r requirements/test.txt # openedx-events -babel==2.18.0 +babel==2.17.0 # via # pydata-sphinx-theme # sphinx -backports-tarfile==1.2.0 - # via - # -r requirements/test.txt - # jaraco-context -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via # -r requirements/test.txt # pydata-sphinx-theme -billiard==4.2.4 +billiard==4.2.1 # via # -r requirements/test.txt # celery -build==1.4.3 +build==1.3.0 # via -r requirements/test.txt -bytecode==0.17.0 - # via - # -r requirements/test.txt - # ddtrace -cachetools==7.0.5 +cachetools==6.2.0 # via # -r requirements/test.txt # tox -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via # -r requirements/test.txt # meilisearch -celery==5.6.3 +celery==5.5.3 # via # -r requirements/test.txt # event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # -r requirements/test.txt # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via # -r requirements/test.txt # cryptography # pynacl -charset-normalizer==3.4.7 +chardet==5.2.0 + # via + # -r requirements/test.txt + # tox +charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.2 +click==8.2.1 # via # -r requirements/test.txt # celery @@ -99,7 +87,7 @@ click-repl==0.3.0 # via # -r requirements/test.txt # celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via # -r requirements/test.txt # edx-toggles @@ -107,26 +95,23 @@ colorama==0.4.6 # via # -r requirements/test.txt # tox -coverage[toml]==7.13.5 +coverage[toml]==7.10.5 # via # -r requirements/test.txt # pytest-cov -cryptography==46.0.7 +cryptography==45.0.6 # via # -r requirements/test.txt # secretstorage -ddtrace==4.7.0 - # via -r requirements/test.txt distlib==0.4.0 # via # -r requirements/test.txt # virtualenv -django==5.2.13 +django==4.2.23 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum - # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -139,22 +124,20 @@ django-crum==0.7.9 # -r requirements/test.txt # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via -r requirements/test.txt django-waffle==5.0.0 # via # -r requirements/test.txt # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via -r requirements/test.txt -dnspython==2.8.0 +dnspython==2.7.0 # via # -r requirements/test.txt # pymongo doc8==0.11.2 # via -r requirements/doc.in -docutils==0.22.4 +docutils==0.22 # via # -r requirements/test.txt # doc8 @@ -166,79 +149,55 @@ edx-ccx-keys==2.0.2 # via # -r requirements/test.txt # openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via # -r requirements/test.txt # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/test.txt # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via -r requirements/test.txt -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # -r requirements/test.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # edx-search -envier==0.6.1 - # via - # -r requirements/test.txt - # ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via # -r requirements/test.txt # edx-search -faker==40.13.0 +faker==37.6.0 # via -r requirements/test.txt -fastavro==1.12.1 +fastavro==1.12.0 # via # -r requirements/test.txt # openedx-events -filelock==3.25.2 +filelock==3.19.1 # via # -r requirements/test.txt - # python-discovery # tox # virtualenv -h11==0.16.0 - # via - # -r requirements/test.txt - # httpcore -httpcore==1.0.9 - # via - # -r requirements/test.txt - # httpx -httpx==0.28.1 - # via - # -r requirements/test.txt - # typesense id==1.5.0 # via # -r requirements/test.txt # twine -idna==3.11 +idna==3.10 # via # -r requirements/test.txt - # anyio - # httpx # requests -imagesize==2.0.0 +imagesize==1.4.1 # via sphinx -importlib-metadata==8.7.1 - # via - # -r requirements/test.txt - # keyring - # opentelemetry-api -iniconfig==2.3.0 +iniconfig==2.1.0 # via # -r requirements/test.txt # pytest @@ -246,11 +205,11 @@ jaraco-classes==3.4.0 # via # -r requirements/test.txt # keyring -jaraco-context==6.1.2 +jaraco-context==6.0.1 # via # -r requirements/test.txt # keyring -jaraco-functools==4.4.0 +jaraco-functools==4.3.0 # via # -r requirements/test.txt # keyring @@ -264,11 +223,11 @@ jinja2==3.1.6 # -r requirements/test.txt # code-annotations # sphinx -keyring==25.7.0 +keyring==25.6.0 # via # -r requirements/test.txt # twine -kombu==5.6.2 +kombu==5.5.4 # via # -r requirements/test.txt # celery @@ -276,7 +235,7 @@ markdown-it-py==4.0.0 # via # -r requirements/test.txt # rich -markupsafe==3.0.3 +markupsafe==3.0.2 # via # -r requirements/test.txt # jinja2 @@ -284,20 +243,20 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -meilisearch==0.40.0 +meilisearch==0.37.0 # via # -r requirements/test.txt # edx-search mongomock==4.3.0 # via -r requirements/test.txt -more-itertools==11.0.2 +more-itertools==10.7.0 # via # -r requirements/test.txt # jaraco-classes # jaraco-functools -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via -r requirements/test.txt -nh3==0.3.4 +nh3==0.3.0 # via # -r requirements/test.txt # readme-renderer @@ -307,11 +266,7 @@ openedx-events==10.5.0 # via # -r requirements/test.txt # event-tracking -opentelemetry-api==1.41.0 - # via - # -r requirements/test.txt - # ddtrace -packaging==26.0 +packaging==25.0 # via # -r requirements/test.txt # build @@ -322,10 +277,9 @@ packaging==26.0 # sphinx # tox # twine -platformdirs==4.9.6 +platformdirs==4.4.0 # via # -r requirements/test.txt - # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -338,44 +292,42 @@ prompt-toolkit==3.0.52 # via # -r requirements/test.txt # click-repl -psutil==7.2.2 +psutil==7.0.0 # via # -r requirements/test.txt # edx-django-utils -pycparser==3.0 +pycparser==2.22 # via # -r requirements/test.txt # cffi -pydantic==2.13.0 +pydantic==2.11.7 # via # -r requirements/test.txt # camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via # -r requirements/test.txt # pydantic -pydata-sphinx-theme==0.16.1 +pydata-sphinx-theme==0.8.0 # via sphinx-book-theme -pygments==2.20.0 +pygments==2.19.2 # via # -r requirements/test.txt - # accessible-pygments # doc8 - # pydata-sphinx-theme # pytest # readme-renderer # rich # sphinx -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/test.txt # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via # -r requirements/test.txt # edx-django-utils -pyproject-api==1.10.0 +pyproject-api==1.9.1 # via # -r requirements/test.txt # tox @@ -383,42 +335,38 @@ pyproject-hooks==1.2.0 # via # -r requirements/test.txt # build -pytest==9.0.3 +pytest==8.4.1 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==7.1.0 +pytest-cov==6.2.1 # via -r requirements/test.txt -pytest-django==4.12.0 +pytest-django==4.11.1 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via # -r requirements/test.txt # celery -python-discovery==1.2.2 - # via - # -r requirements/test.txt - # tox - # virtualenv python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2026.1.post1 +pytz==2025.2 # via # -r requirements/test.txt # event-tracking # mongomock -pyyaml==6.0.3 +pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations + # sphinx-book-theme readme-renderer==44.0 # via # -r requirements/test.txt # twine -requests==2.33.1 +requests==2.32.5 # via # -r requirements/test.txt # id @@ -430,19 +378,17 @@ requests-toolbelt==1.0.0 # via # -r requirements/test.txt # twine -restructuredtext-lint==2.0.2 +restructuredtext-lint==1.4.0 # via doc8 rfc3986==2.0.0 # via # -r requirements/test.txt # twine -rich==15.0.0 +rich==14.1.0 # via # -r requirements/test.txt # twine -roman-numerals==4.1.0 - # via sphinx -secretstorage==3.5.0 +secretstorage==3.3.3 # via # -r requirements/test.txt # keyring @@ -458,16 +404,16 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via sphinx -soupsieve==2.8.3 +soupsieve==2.8 # via # -r requirements/test.txt # beautifulsoup4 -sphinx==9.0.4 +sphinx==3.5.3 # via # -r requirements/doc.in # pydata-sphinx-theme # sphinx-book-theme -sphinx-book-theme==1.2.0 +sphinx-book-theme==0.3.3 # via -r requirements/doc.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -481,11 +427,11 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sqlparse==0.5.5 +sqlparse==0.5.3 # via # -r requirements/test.txt # django -stevedore==5.7.0 +stevedore==5.5.0 # via # -r requirements/test.txt # code-annotations @@ -496,42 +442,27 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomli-w==1.2.0 - # via - # -r requirements/test.txt - # tox -tox==4.52.1 +tox==4.28.4 # via -r requirements/test.txt -twine==6.2.0 +twine==6.1.0 # via -r requirements/test.txt -typesense==2.0.0 - # via - # -r requirements/test.txt - # edx-search typing-extensions==4.15.0 # via # -r requirements/test.txt - # anyio # beautifulsoup4 # edx-opaque-keys - # opentelemetry-api # pydantic # pydantic-core - # pydata-sphinx-theme - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via # -r requirements/test.txt # pydantic -tzdata==2026.1 +tzdata==2025.2 # via # -r requirements/test.txt + # faker # kombu -tzlocal==5.3.1 - # via - # -r requirements/test.txt - # celery urllib3==1.26.20 # via # -r requirements/test.txt @@ -544,22 +475,14 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.2.1 +virtualenv==20.34.0 # via # -r requirements/test.txt # tox -wcwidth==0.6.0 +wcwidth==0.2.13 # via # -r requirements/test.txt # prompt-toolkit -wrapt==2.1.2 - # via - # -r requirements/test.txt - # ddtrace -zipp==3.23.0 - # via - # -r requirements/test.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index e00a0547..bfdc0512 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,24 +1,22 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade # -build==1.4.3 +build==1.3.0 # via pip-tools -click==8.3.2 +click==8.2.1 # via pip-tools -packaging==26.0 - # via - # build - # wheel -pip-tools==7.5.3 +packaging==25.0 + # via build +pip-tools==7.5.0 # via -r requirements/pip-tools.in pyproject-hooks==1.2.0 # via # build # pip-tools -wheel==0.46.3 +wheel==0.45.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/pip.txt b/requirements/pip.txt index 341a251b..fca60af6 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,16 +1,16 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade # -packaging==26.0 - # via wheel -wheel==0.46.3 +wheel==0.45.1 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==26.0.1 - # via -r requirements/pip.in -setuptools==82.0.1 +pip==24.2 + # via + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -r requirements/pip.in +setuptools==80.9.0 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index d6f8eb1a..fe0a2766 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade @@ -12,67 +12,57 @@ annotated-types==0.7.0 # via # -r requirements/test.txt # pydantic -anyio==4.13.0 - # via - # -r requirements/test.txt - # httpx -asgiref==3.11.1 +asgiref==3.9.1 # via # -r requirements/test.txt # django -astroid==4.0.4 +astroid==3.3.11 # via # pylint # pylint-celery -attrs==26.1.0 +attrs==25.3.0 # via # -r requirements/test.txt # openedx-events -backports-tarfile==1.2.0 - # via - # -r requirements/test.txt - # jaraco-context -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via -r requirements/test.txt -billiard==4.2.4 +billiard==4.2.1 # via # -r requirements/test.txt # celery -build==1.4.3 +build==1.3.0 # via -r requirements/test.txt -bytecode==0.17.0 - # via - # -r requirements/test.txt - # ddtrace -cachetools==7.0.5 +cachetools==6.2.0 # via # -r requirements/test.txt # tox -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via # -r requirements/test.txt # meilisearch -celery==5.6.3 +celery==5.5.3 # via # -r requirements/test.txt # event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # -r requirements/test.txt # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via # -r requirements/test.txt # cryptography # pynacl -charset-normalizer==3.4.7 +chardet==5.2.0 + # via + # -r requirements/test.txt + # tox +charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.2 +click==8.2.1 # via # -r requirements/test.txt # celery @@ -97,7 +87,7 @@ click-repl==0.3.0 # via # -r requirements/test.txt # celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via # -r requirements/test.txt # edx-lint @@ -106,28 +96,25 @@ colorama==0.4.6 # via # -r requirements/test.txt # tox -coverage[toml]==7.13.5 +coverage[toml]==7.10.5 # via # -r requirements/test.txt # pytest-cov -cryptography==46.0.7 +cryptography==45.0.6 # via # -r requirements/test.txt # secretstorage -ddtrace==4.7.0 - # via -r requirements/test.txt -dill==0.4.1 +dill==0.4.0 # via pylint distlib==0.4.0 # via # -r requirements/test.txt # virtualenv -django==5.2.13 +django==4.2.23 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum - # django-model-utils # django-stubs # django-stubs-ext # django-waffle @@ -142,26 +129,24 @@ django-crum==0.7.9 # -r requirements/test.txt # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via -r requirements/test.txt -django-stubs==6.0.2 +django-stubs==5.2.2 # via djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==5.2.2 # via django-stubs django-waffle==5.0.0 # via # -r requirements/test.txt # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via -r requirements/test.txt -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.2 # via -r requirements/quality.in -dnspython==2.8.0 +dnspython==2.7.0 # via # -r requirements/test.txt # pymongo -docutils==0.22.4 +docutils==0.22 # via # -r requirements/test.txt # readme-renderer @@ -169,83 +154,59 @@ edx-ccx-keys==2.0.2 # via # -r requirements/test.txt # openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via # -r requirements/test.txt # edx-toggles # event-tracking # openedx-events -edx-lint==6.0.0 +edx-lint==5.6.0 # via -r requirements/quality.in -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/test.txt # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via -r requirements/test.txt -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # -r requirements/test.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # edx-search -envier==0.6.1 - # via - # -r requirements/test.txt - # ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via # -r requirements/test.txt # edx-search -faker==40.13.0 +faker==37.6.0 # via -r requirements/test.txt -fastavro==1.12.1 +fastavro==1.12.0 # via # -r requirements/test.txt # openedx-events -filelock==3.25.2 +filelock==3.19.1 # via # -r requirements/test.txt - # python-discovery # tox # virtualenv -h11==0.16.0 - # via - # -r requirements/test.txt - # httpcore -httpcore==1.0.9 - # via - # -r requirements/test.txt - # httpx -httpx==0.28.1 - # via - # -r requirements/test.txt - # typesense id==1.5.0 # via # -r requirements/test.txt # twine -idna==3.11 +idna==3.10 # via # -r requirements/test.txt - # anyio - # httpx # requests -importlib-metadata==8.7.1 - # via - # -r requirements/test.txt - # keyring - # opentelemetry-api -iniconfig==2.3.0 +iniconfig==2.1.0 # via # -r requirements/test.txt # pytest -isort==8.0.1 +isort==6.0.1 # via # -r requirements/quality.in # pylint @@ -253,11 +214,11 @@ jaraco-classes==3.4.0 # via # -r requirements/test.txt # keyring -jaraco-context==6.1.2 +jaraco-context==6.0.1 # via # -r requirements/test.txt # keyring -jaraco-functools==4.4.0 +jaraco-functools==4.3.0 # via # -r requirements/test.txt # keyring @@ -270,21 +231,19 @@ jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations -keyring==25.7.0 +keyring==25.6.0 # via # -r requirements/test.txt # twine -kombu==5.6.2 +kombu==5.5.4 # via # -r requirements/test.txt # celery -librt==0.9.0 - # via mypy markdown-it-py==4.0.0 # via # -r requirements/test.txt # rich -markupsafe==3.0.3 +markupsafe==3.0.2 # via # -r requirements/test.txt # jinja2 @@ -294,24 +253,24 @@ mdurl==0.1.2 # via # -r requirements/test.txt # markdown-it-py -meilisearch==0.40.0 +meilisearch==0.37.0 # via # -r requirements/test.txt # edx-search mongomock==4.3.0 # via -r requirements/test.txt -more-itertools==11.0.2 +more-itertools==10.7.0 # via # -r requirements/test.txt # jaraco-classes # jaraco-functools -mypy==1.20.1 +mypy==1.17.1 # via -r requirements/quality.in mypy-extensions==1.1.0 # via mypy -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via -r requirements/test.txt -nh3==0.3.4 +nh3==0.3.0 # via # -r requirements/test.txt # readme-renderer @@ -321,11 +280,7 @@ openedx-events==10.5.0 # via # -r requirements/test.txt # event-tracking -opentelemetry-api==1.41.0 - # via - # -r requirements/test.txt - # ddtrace -packaging==26.0 +packaging==25.0 # via # -r requirements/test.txt # build @@ -335,13 +290,12 @@ packaging==26.0 # pytest # tox # twine -pathspec==1.0.4 +pathspec==0.12.1 # via mypy -platformdirs==4.9.6 +platformdirs==4.4.0 # via # -r requirements/test.txt # pylint - # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -354,33 +308,33 @@ prompt-toolkit==3.0.52 # via # -r requirements/test.txt # click-repl -psutil==7.2.2 +psutil==7.0.0 # via # -r requirements/test.txt # edx-django-utils pycodestyle==2.14.0 # via -r requirements/quality.in -pycparser==3.0 +pycparser==2.22 # via # -r requirements/test.txt # cffi -pydantic==2.13.0 +pydantic==2.11.7 # via # -r requirements/test.txt # camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via # -r requirements/test.txt # pydantic pydocstyle==6.3.0 # via -r requirements/quality.in -pygments==2.20.0 +pygments==2.19.2 # via # -r requirements/test.txt # pytest # readme-renderer # rich -pylint==4.0.5 +pylint==3.3.8 # via # edx-lint # pylint-celery @@ -388,22 +342,22 @@ pylint==4.0.5 # pylint-plugin-utils pylint-celery==0.3 # via edx-lint -pylint-django==2.7.0 +pylint-django==2.6.1 # via edx-lint pylint-plugin-utils==0.9.0 # via # pylint-celery # pylint-django -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/test.txt # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via # -r requirements/test.txt # edx-django-utils -pyproject-api==1.10.0 +pyproject-api==1.9.1 # via # -r requirements/test.txt # tox @@ -411,34 +365,29 @@ pyproject-hooks==1.2.0 # via # -r requirements/test.txt # build -pytest==9.0.3 +pytest==8.4.1 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==7.1.0 +pytest-cov==6.2.1 # via -r requirements/test.txt -pytest-django==4.12.0 +pytest-django==4.11.1 # via -r requirements/test.txt python-dateutil==2.9.0.post0 # via # -r requirements/test.txt # celery -python-discovery==1.2.2 - # via - # -r requirements/test.txt - # tox - # virtualenv python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2026.1.post1 +pytz==2025.2 # via # -r requirements/test.txt # event-tracking # mongomock -pyyaml==6.0.3 +pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations @@ -446,9 +395,10 @@ readme-renderer==44.0 # via # -r requirements/test.txt # twine -requests==2.33.1 +requests==2.32.5 # via # -r requirements/test.txt + # djangorestframework-stubs # id # meilisearch # requests-toolbelt @@ -461,11 +411,11 @@ rfc3986==2.0.0 # via # -r requirements/test.txt # twine -rich==15.0.0 +rich==14.1.0 # via # -r requirements/test.txt # twine -secretstorage==3.5.0 +secretstorage==3.3.3 # via # -r requirements/test.txt # keyring @@ -482,15 +432,15 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via pydocstyle -soupsieve==2.8.3 +soupsieve==2.8 # via # -r requirements/test.txt # beautifulsoup4 -sqlparse==0.5.5 +sqlparse==0.5.3 # via # -r requirements/test.txt # django -stevedore==5.7.0 +stevedore==5.5.0 # via # -r requirements/test.txt # code-annotations @@ -500,61 +450,47 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomli-w==1.2.0 - # via - # -r requirements/test.txt - # tox -tomlkit==0.14.0 +tomlkit==0.13.3 # via pylint -tox==4.52.1 +tox==4.28.4 # via -r requirements/test.txt -twine==6.2.0 +twine==6.1.0 # via -r requirements/test.txt types-beautifulsoup4==4.12.0.20250516 # via -r requirements/quality.in -types-html5lib==1.1.11.20260408 +types-html5lib==1.1.11.20250809 # via types-beautifulsoup4 -types-pyyaml==6.0.12.20260408 +types-pyyaml==6.0.12.20250822 # via # django-stubs # djangorestframework-stubs types-requests==2.31.0.6 - # via -r requirements/quality.in + # via + # -r requirements/quality.in + # djangorestframework-stubs types-urllib3==1.26.25.14 # via types-requests -types-webencodings==0.5.0.20260408 - # via types-html5lib -typesense==2.0.0 - # via - # -r requirements/test.txt - # edx-search typing-extensions==4.15.0 # via # -r requirements/test.txt - # anyio # beautifulsoup4 # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys # mypy - # opentelemetry-api # pydantic # pydantic-core - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via # -r requirements/test.txt # pydantic -tzdata==2026.1 +tzdata==2025.2 # via # -r requirements/test.txt + # faker # kombu -tzlocal==5.3.1 - # via - # -r requirements/test.txt - # celery urllib3==1.26.20 # via # -r requirements/test.txt @@ -567,22 +503,14 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.2.1 +virtualenv==20.34.0 # via # -r requirements/test.txt # tox -wcwidth==0.6.0 +wcwidth==0.2.13 # via # -r requirements/test.txt # prompt-toolkit -wrapt==2.1.2 - # via - # -r requirements/test.txt - # ddtrace -zipp==3.23.0 - # via - # -r requirements/test.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/test.txt b/requirements/test.txt index cddd1c28..87b4932d 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # make upgrade @@ -12,59 +12,49 @@ annotated-types==0.7.0 # via # -r requirements/base.txt # pydantic -anyio==4.13.0 - # via - # -r requirements/base.txt - # httpx -asgiref==3.11.1 +asgiref==3.9.1 # via # -r requirements/base.txt # django -attrs==26.1.0 +attrs==25.3.0 # via # -r requirements/base.txt # openedx-events -backports-tarfile==1.2.0 - # via jaraco-context -beautifulsoup4==4.14.3 +beautifulsoup4==4.13.5 # via -r requirements/base.txt -billiard==4.2.4 +billiard==4.2.1 # via # -r requirements/base.txt # celery -build==1.4.3 +build==1.3.0 # via -r requirements/test.in -bytecode==0.17.0 - # via - # -r requirements/base.txt - # ddtrace -cachetools==7.0.5 +cachetools==6.2.0 # via tox -camel-converter[pydantic]==5.1.0 +camel-converter[pydantic]==4.0.1 # via # -r requirements/base.txt # meilisearch -celery==5.6.3 +celery==5.5.3 # via # -r requirements/base.txt # event-tracking -certifi==2026.2.25 +certifi==2025.8.3 # via # -r requirements/base.txt # elasticsearch - # httpcore - # httpx # requests -cffi==2.0.0 +cffi==1.17.1 # via # -r requirements/base.txt # cryptography # pynacl -charset-normalizer==3.4.7 +chardet==5.2.0 + # via tox +charset-normalizer==3.4.3 # via # -r requirements/base.txt # requests -click==8.3.2 +click==8.2.1 # via # -r requirements/base.txt # celery @@ -85,26 +75,23 @@ click-repl==0.3.0 # via # -r requirements/base.txt # celery -code-annotations==2.3.2 +code-annotations==2.3.0 # via # -r requirements/base.txt # -r requirements/test.in # edx-toggles colorama==0.4.6 # via tox -coverage[toml]==7.13.5 +coverage[toml]==7.10.5 # via pytest-cov -cryptography==46.0.7 +cryptography==45.0.6 # via secretstorage -ddtrace==4.7.0 - # via -r requirements/base.txt distlib==0.4.0 # via virtualenv # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # django-crum - # django-model-utils # django-waffle # djangorestframework # edx-django-utils @@ -117,99 +104,73 @@ django-crum==0.7.9 # -r requirements/base.txt # edx-django-utils # edx-toggles -django-model-utils==5.0.0 - # via -r requirements/base.txt django-waffle==5.0.0 # via # -r requirements/base.txt # edx-django-utils # edx-toggles -djangorestframework==3.17.1 +djangorestframework==3.16.1 # via -r requirements/base.txt -dnspython==2.8.0 +dnspython==2.7.0 # via # -r requirements/base.txt # pymongo -docutils==0.22.4 +docutils==0.22 # via readme-renderer edx-ccx-keys==2.0.2 # via # -r requirements/base.txt # openedx-events -edx-django-utils==8.0.1 +edx-django-utils==8.0.0 # via # -r requirements/base.txt # edx-toggles # event-tracking # openedx-events -edx-opaque-keys[django]==4.0.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/base.txt # edx-ccx-keys # openedx-events -edx-search==5.0.0 +edx-search==4.1.3 # via -r requirements/base.txt -edx-toggles==6.0.0 +edx-toggles==5.4.1 # via # -r requirements/base.txt # edx-search # event-tracking elasticsearch==7.13.4 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt # edx-search -envier==0.6.1 - # via - # -r requirements/base.txt - # ddtrace -event-tracking==4.0.0 +event-tracking==3.3.0 # via # -r requirements/base.txt # edx-search -faker==40.13.0 +faker==37.6.0 # via -r requirements/test.in -fastavro==1.12.1 +fastavro==1.12.0 # via # -r requirements/base.txt # openedx-events -filelock==3.25.2 +filelock==3.19.1 # via - # python-discovery # tox # virtualenv -h11==0.16.0 - # via - # -r requirements/base.txt - # httpcore -httpcore==1.0.9 - # via - # -r requirements/base.txt - # httpx -httpx==0.28.1 - # via - # -r requirements/base.txt - # typesense id==1.5.0 # via twine -idna==3.11 +idna==3.10 # via # -r requirements/base.txt - # anyio - # httpx # requests -importlib-metadata==8.7.1 - # via - # -r requirements/base.txt - # keyring - # opentelemetry-api -iniconfig==2.3.0 +iniconfig==2.1.0 # via pytest jaraco-classes==3.4.0 # via keyring -jaraco-context==6.1.2 +jaraco-context==6.0.1 # via keyring -jaraco-functools==4.4.0 +jaraco-functools==4.3.0 # via keyring jeepney==0.9.0 # via @@ -219,33 +180,33 @@ jinja2==3.1.6 # via # -r requirements/base.txt # code-annotations -keyring==25.7.0 +keyring==25.6.0 # via twine -kombu==5.6.2 +kombu==5.5.4 # via # -r requirements/base.txt # celery markdown-it-py==4.0.0 # via rich -markupsafe==3.0.3 +markupsafe==3.0.2 # via # -r requirements/base.txt # jinja2 mdurl==0.1.2 # via markdown-it-py -meilisearch==0.40.0 +meilisearch==0.37.0 # via # -r requirements/base.txt # edx-search mongomock==4.3.0 # via -r requirements/test.in -more-itertools==11.0.2 +more-itertools==10.7.0 # via # jaraco-classes # jaraco-functools -mysqlclient==2.2.8 +mysqlclient==2.2.7 # via -r requirements/base.txt -nh3==0.3.4 +nh3==0.3.0 # via readme-renderer openedx-atlas==0.7.0 # via -r requirements/base.txt @@ -253,11 +214,7 @@ openedx-events==10.5.0 # via # -r requirements/base.txt # event-tracking -opentelemetry-api==1.41.0 - # via - # -r requirements/base.txt - # ddtrace -packaging==26.0 +packaging==25.0 # via # -r requirements/base.txt # build @@ -267,9 +224,8 @@ packaging==26.0 # pytest # tox # twine -platformdirs==4.9.6 +platformdirs==4.4.0 # via - # python-discovery # tox # virtualenv pluggy==1.6.0 @@ -281,72 +237,68 @@ prompt-toolkit==3.0.52 # via # -r requirements/base.txt # click-repl -psutil==7.2.2 +psutil==7.0.0 # via # -r requirements/base.txt # edx-django-utils -pycparser==3.0 +pycparser==2.22 # via # -r requirements/base.txt # cffi -pydantic==2.13.0 +pydantic==2.11.7 # via # -r requirements/base.txt # camel-converter -pydantic-core==2.46.0 +pydantic-core==2.33.2 # via # -r requirements/base.txt # pydantic -pygments==2.20.0 +pygments==2.19.2 # via # pytest # readme-renderer # rich -pymongo==4.16.0 +pymongo==4.14.1 # via # -r requirements/base.txt # edx-opaque-keys # event-tracking -pynacl==1.6.2 +pynacl==1.5.0 # via # -r requirements/base.txt # edx-django-utils -pyproject-api==1.10.0 +pyproject-api==1.9.1 # via tox pyproject-hooks==1.2.0 # via build -pytest==9.0.3 +pytest==8.4.1 # via # pytest-cov # pytest-django -pytest-cov==7.1.0 +pytest-cov==6.2.1 # via -r requirements/test.in -pytest-django==4.12.0 +pytest-django==4.11.1 # via -r requirements/test.in python-dateutil==2.9.0.post0 # via # -r requirements/base.txt # celery -python-discovery==1.2.2 - # via - # tox - # virtualenv python-slugify==8.0.4 # via # -r requirements/base.txt # code-annotations -pytz==2026.1.post1 +pytz==2025.2 # via # -r requirements/base.txt # event-tracking # mongomock -pyyaml==6.0.3 +pyyaml==6.0.2 # via # -r requirements/base.txt # code-annotations readme-renderer==44.0 # via twine -requests==2.33.1 +requests==2.32.5 # via # -r requirements/base.txt # id @@ -357,9 +309,9 @@ requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==15.0.0 +rich==14.1.0 # via twine -secretstorage==3.5.0 +secretstorage==3.3.3 # via keyring sentinels==1.1.1 # via mongomock @@ -369,15 +321,15 @@ six==1.17.0 # edx-ccx-keys # event-tracking # python-dateutil -soupsieve==2.8.3 +soupsieve==2.8 # via # -r requirements/base.txt # beautifulsoup4 -sqlparse==0.5.5 +sqlparse==0.5.3 # via # -r requirements/base.txt # django -stevedore==5.7.0 +stevedore==5.5.0 # via # -r requirements/base.txt # code-annotations @@ -387,39 +339,27 @@ text-unidecode==1.3 # via # -r requirements/base.txt # python-slugify -tomli-w==1.2.0 - # via tox -tox==4.52.1 +tox==4.28.4 # via -r requirements/test.in -twine==6.2.0 +twine==6.1.0 # via -r requirements/test.in -typesense==2.0.0 - # via - # -r requirements/base.txt - # edx-search typing-extensions==4.15.0 # via # -r requirements/base.txt - # anyio # beautifulsoup4 # edx-opaque-keys - # opentelemetry-api # pydantic # pydantic-core - # typesense # typing-inspection -typing-inspection==0.4.2 +typing-inspection==0.4.1 # via # -r requirements/base.txt # pydantic -tzdata==2026.1 +tzdata==2025.2 # via # -r requirements/base.txt + # faker # kombu -tzlocal==5.3.1 - # via - # -r requirements/base.txt - # celery urllib3==1.26.20 # via # -r requirements/base.txt @@ -432,20 +372,12 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.2.1 +virtualenv==20.34.0 # via tox -wcwidth==0.6.0 +wcwidth==0.2.13 # via # -r requirements/base.txt # prompt-toolkit -wrapt==2.1.2 - # via - # -r requirements/base.txt - # ddtrace -zipp==3.23.0 - # via - # -r requirements/base.txt - # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index 6eef312c..19c52af7 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,6 @@ """ Package metadata for forum. """ - import os import re import sys diff --git a/tests/test_backends/test_mongodb/test_comments.py b/tests/test_backends/test_mongodb/test_comments.py index 05831775..4b255054 100644 --- a/tests/test_backends/test_mongodb/test_comments.py +++ b/tests/test_backends/test_mongodb/test_comments.py @@ -2,7 +2,6 @@ """ Tests for the `Comment` model. """ - from forum.backends.mongodb import Comment diff --git a/tests/test_management/test_commands/test_migration_commands.py b/tests/test_management/test_commands/test_migration_commands.py index c6ac9281..06965897 100644 --- a/tests/test_management/test_commands/test_migration_commands.py +++ b/tests/test_management/test_commands/test_migration_commands.py @@ -23,6 +23,7 @@ ) from forum.utils import get_trunc_title + pytestmark = pytest.mark.django_db From 9ae39d74f4411359d388f394eecb554be360891c Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Fri, 17 Apr 2026 12:17:35 +0530 Subject: [PATCH 19/22] fix: team posts not appearing after enabling MySQL waffle flag (#35) Team posts are not appearing in the discussion/forum after being created. The post creation flow completes successfully without any errors, but the newly created posts are not visible in the UI. --- forum/backends/mysql/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 63102e44..d98381c5 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1231,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, From 5fee94e7aee2bb2834301c1813b403870633cca0 Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Wed, 22 Apr 2026 17:44:08 +0530 Subject: [PATCH 20/22] fix: mysql referential integrity error in update_user during retire_forum (#38) After migrating the forum backend from MongoDB to MySQL, user retirement via the retire_forum API was failing due to MySQL-specific constraints that were not handled correctly in the existing implementation. --- forum/__init__.py | 2 +- forum/backends/mysql/api.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index fa16b91e..3fa31c34 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index d98381c5..9feb0f79 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -2441,12 +2441,14 @@ def update_user(user_id: str, data: dict[str, Any]) -> int: if "username" in data: user.username = data["username"] - if "email" in data: - user.email = data["email"] + # 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() From 028ab29062bee4551c7d7f4b6134822137fe73ca Mon Sep 17 00:00:00 2001 From: Ehtesham Alam Date: Thu, 23 Apr 2026 17:48:01 +0530 Subject: [PATCH 21/22] fix: mysql referential integrity error in update_user during retire_forum (#39) A previous fix was introduced in PR #38, but the issue persists. The failure occurs because user.save() attempts to update the email field in the auth_user table, which has already been modified by LMS, leading to conflicts. --- forum/__init__.py | 2 +- forum/api/users.py | 22 ++++++++++++++-------- forum/backends/mysql/api.py | 9 ++++++--- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/forum/__init__.py b/forum/__init__.py index 3fa31c34..43406aba 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.5.1" +__version__ = "0.5.2" diff --git a/forum/api/users.py b/forum/api/users.py index 9ebd808c..fbe5608b 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -9,6 +9,7 @@ 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 @@ -137,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) diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 9feb0f79..b8d3b070 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -1359,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 @@ -2439,8 +2439,10 @@ 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"] + 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"] @@ -2451,7 +2453,8 @@ def update_user(user_id: str, data: dict[str, Any]) -> int: # 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 @@ -2461,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) From c98ed5f85f01a37ef0c263eff5b6270b7ef0036d Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Fri, 24 Apr 2026 13:42:39 +0530 Subject: [PATCH 22/22] feat: update init file --- forum/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forum/__init__.py b/forum/__init__.py index 43406aba..acbbc407 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.5.2" +__version__ = "0.5.3"