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