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