diff --git a/forum/api/comments.py b/forum/api/comments.py index 38a9a8bd..429277db 100644 --- a/forum/api/comments.py +++ b/forum/api/comments.py @@ -220,12 +220,14 @@ 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,7 +246,9 @@ def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str backend, exclude_fields=["endorsement", "sk"], ) - backend.delete_comment(comment_id) + # Soft delete comment instead of hard delete + backend.soft_delete_comment(comment_id, deleted_by) + # backend.delete_comment(comment_id) author_id = comment["author_id"] comment_course_id = comment["course_id"] parent_comment_id = data["parent_id"] diff --git a/forum/api/threads.py b/forum/api/threads.py index 5eb60768..4ba8ce0b 100644 --- a/forum/api/threads.py +++ b/forum/api/threads.py @@ -159,12 +159,14 @@ 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 +179,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) + # Soft delete comments and thread instead of hard delete + 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: @@ -187,7 +191,9 @@ 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) + # Soft delete thread instead of hard delete + 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 diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index cddc412d..e7eb3763 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -596,6 +596,7 @@ def handle_threads_query( base_query: dict[str, Any] = { "_id": {"$in": comment_thread_obj_ids}, "context": context, + "is_deleted": {"$ne": True}, # Exclude soft deleted threads } # Group filtering @@ -1495,6 +1496,9 @@ def get_user_by_username(username: str | None) -> dict[str, Any] | None: def get_comment(comment_id: str) -> dict[str, Any] | None: """Get comment from id.""" comment = Comment().get(comment_id) + # Return None if comment is soft deleted + if comment and comment.get('is_deleted'): + return None return comment @staticmethod @@ -1503,6 +1507,9 @@ def get_thread(thread_id: str) -> dict[str, Any] | None: thread = CommentThread().get(thread_id) if not thread: return None + # Return None if thread is soft deleted + if thread.get('is_deleted'): + return None return thread @staticmethod @@ -1568,6 +1575,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: str = None) -> int: + """Soft delete thread by marking it as deleted.""" + return CommentThread().update( + thread_id, + is_deleted=True, + deleted_at=datetime.now(), + deleted_by=deleted_by + ) + + @staticmethod + def soft_delete_comments_of_a_thread(thread_id: str, deleted_by: str = None) -> None: + """Soft delete all comments of a thread by marking them as deleted.""" + deleted_at = datetime.now() + for comment in Comment().get_list( + comment_thread_id=ObjectId(thread_id), + depth=0, + parent_id=None, + ): + Comment().update( + comment["_id"], + is_deleted=True, + deleted_at=deleted_at, + deleted_by=deleted_by + ) + + @staticmethod + def soft_delete_comment(comment_id: str, deleted_by: str = None) -> None: + """Soft delete comment by marking it as deleted.""" + Comment().update( + comment_id, + is_deleted=True, + deleted_at=datetime.now(), + deleted_by=deleted_by + ) + @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1723,7 +1766,35 @@ 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']) + + # Get all deleted thread IDs in one query + deleted_thread_ids = set() + if comment_thread_ids: + deleted_threads = CommentThread()._collection.find( + {"_id": {"$in": list(comment_thread_ids)}, "is_deleted": True}, + {"_id": 1} + ) + deleted_thread_ids = {thread['_id'] for thread in deleted_threads} + + # Filter out comments that belong to deleted threads + filtered_contents = [] + for content in contents: + if content.get('_type') == 'Comment': + thread_id = content.get('comment_thread_id') + if thread_id and thread_id in deleted_thread_ids: + continue # Skip comment if its thread is deleted + filtered_contents.append(content) + + return filtered_contents @staticmethod def get_user_thread_filter(course_id: str) -> dict[str, Any]: @@ -1731,6 +1802,7 @@ def get_user_thread_filter(course_id: str) -> dict[str, Any]: return { "_type": {"$in": [CommentThread.content_type]}, "course_id": {"$in": [course_id]}, + "is_deleted": {"$ne": True}, # Exclude soft deleted threads } @staticmethod diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 7f9af685..1f6f2a9e 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 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 61126624..cea2dae5 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 diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index c8633476..110176ef 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -653,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 + pk__in=mysql_comment_thread_ids, context=context, is_deleted=False # Exclude soft deleted threads ) # Group filtering @@ -986,6 +986,15 @@ 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: str = None) -> None: + """Soft delete comments of a thread by marking them as deleted.""" + Comment.objects.filter(comment_thread__pk=thread_id, parent=None).update( + is_deleted=True, + deleted_at=timezone.now(), + deleted_by=deleted_by + ) + @classmethod def delete_subscriptions_of_a_thread(cls, thread_id: str) -> None: """Delete subscriptions of a thread.""" @@ -1450,7 +1459,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) + 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 +1539,15 @@ def delete_comment(cls, comment_id: str) -> None: comment.delete() + @staticmethod + def soft_delete_comment(comment_id: str, deleted_by: str = None) -> None: + """Soft delete comment by marking it as deleted.""" + comment = Comment.objects.get(pk=comment_id) + comment.is_deleted = True + comment.deleted_at = timezone.now() + comment.deleted_by = deleted_by + comment.save() + @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.""" @@ -1716,7 +1734,7 @@ def get_user(user_id: str, get_full_dict: bool = True) -> dict[str, Any] | None: def get_thread(thread_id: str) -> dict[str, Any] | None: """Return thread from thread_id.""" try: - thread = CommentThread.objects.get(pk=thread_id) + thread = CommentThread.objects.get(pk=thread_id, is_deleted=False) # Exclude soft deleted threads except CommentThread.DoesNotExist: return None return thread.to_dict() @@ -1771,6 +1789,19 @@ def delete_thread(thread_id: str) -> int: thread.delete() return 1 + @staticmethod + def soft_delete_thread(thread_id: str, deleted_by: 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() + thread.deleted_by = deleted_by + thread.save() + return 1 + @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" @@ -1911,14 +1942,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} + 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 +2189,11 @@ 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 + ) + threads = CommentThread.objects.filter(**thread_filters).filter(is_deleted=False) # Exclude soft deleted threads sort_key = kwargs.get("sort_key") if sort_key: diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index e149daa6..d87f045a 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -129,6 +129,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 +286,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 +342,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 +531,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/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..21016bef 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -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. """