Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion forum/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Openedx forum app.
"""

__version__ = "0.3.9"
__version__ = "0.4.0"
13 changes: 9 additions & 4 deletions forum/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
96 changes: 90 additions & 6 deletions forum/api/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)
Comment on lines +259 to +277
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delete_comment function updates stats for all comments regardless of whether they are anonymous. However, in the restore_comment and delete_thread functions, stats are only updated when content is not anonymous (checking anonymous or anonymous_to_peers flags). This inconsistency means that deleting anonymous content will incorrectly update stats, but restoring it won't restore those stats, leading to stat drift. The delete_comment function should check if the comment is anonymous before updating stats.

Copilot uses AI. Check for mistakes.
return data


Expand Down Expand Up @@ -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)

Comment on lines +435 to +451
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests have been added for the new restore functionality (restore_comment, restore_thread, restore_user_deleted_comments, restore_user_deleted_threads). Given that this PR adds significant new functionality with complex stat updating logic, comprehensive tests should be added to verify that restoration works correctly, especially for edge cases like restoring anonymous content, restoring parents without children, and bulk restore operations.

Copilot uses AI. Check for mistakes.

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
)
2 changes: 2 additions & 0 deletions forum/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"):
Expand Down
83 changes: 79 additions & 4 deletions forum/api/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Comment on lines +439 to +455
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests have been added for the get_deleted_threads_for_course and get_deleted_comments_for_course functions. These functions implement pagination and filtering logic that should be tested to ensure they work correctly across both MySQL and MongoDB backends.

Copilot uses AI. Check for mistakes.


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
)
4 changes: 3 additions & 1 deletion forum/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -320,7 +322,7 @@ def get_user_course_stats(
"""Get user course stats."""
backend = get_backend(course_id)()
sort_criterion = backend.get_user_sort_criterion(sort_key)
exclude_from_stats = ["_id", "course_id"]
exclude_from_stats = ["_id", "course_id", "deleted_count"]
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclude_from_stats list now includes "deleted_count" which means this computed field won't be returned in the user stats API. However, deleted_count is computed in the to_dict method (line 88-90 in models.py) and appears to be a useful stat. Consider whether this exclusion is intentional or if deleted_count should be exposed to API consumers.

Suggested change
exclude_from_stats = ["_id", "course_id", "deleted_count"]
exclude_from_stats = ["_id", "course_id"]

Copilot uses AI. Check for mistakes.
if not with_timestamps:
exclude_from_stats.append("last_activity_at")

Expand Down
24 changes: 24 additions & 0 deletions forum/backends/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading