Skip to content

Commit 8f6699d

Browse files
authored
feat: soft delete feature (#4)
Implements soft delete functionality for discussion threads, responses, and comments using the is_deleted flag instead of permanently deleting records. This enables safe deletion and restoration of discussion content while preserving existing data.
1 parent 1eb7b28 commit 8f6699d

19 files changed

Lines changed: 1239 additions & 83 deletions

File tree

forum/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Openedx forum app.
33
"""
44

5-
__version__ = "0.3.9"
5+
__version__ = "0.4.0"

forum/api/__init__.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
create_parent_comment,
99
delete_comment,
1010
get_course_id_by_comment,
11+
get_deleted_comments_for_course,
1112
get_parent_comment,
1213
get_user_comments,
14+
restore_comment,
15+
restore_user_deleted_comments,
1316
update_comment,
1417
)
15-
from .flags import (
16-
update_comment_flag,
17-
update_thread_flag,
18-
)
18+
from .flags import update_comment_flag, update_thread_flag
1919
from .pins import pin_thread, unpin_thread
2020
from .search import search_threads
2121
from .subscriptions import (
@@ -28,8 +28,11 @@
2828
create_thread,
2929
delete_thread,
3030
get_course_id_by_thread,
31+
get_deleted_threads_for_course,
3132
get_thread,
3233
get_user_threads,
34+
restore_thread,
35+
restore_user_deleted_threads,
3336
update_thread,
3437
)
3538
from .users import (
@@ -73,6 +76,8 @@
7376
"get_user_course_stats",
7477
"get_user_subscriptions",
7578
"get_user_threads",
79+
"get_deleted_comments_for_course",
80+
"get_deleted_threads_for_course",
7681
"mark_thread_as_read",
7782
"pin_thread",
7883
"retire_user",

forum/api/comments.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,16 @@ def update_comment(
220220
raise error
221221

222222

223-
def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str, Any]:
223+
def delete_comment(
224+
comment_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None
225+
) -> dict[str, Any]:
224226
"""
225227
Delete a comment.
226228
227229
Parameters:
228230
comment_id: The ID of the comment to be deleted.
231+
course_id: The ID of the course (optional).
232+
deleted_by: The ID of the user performing the delete (optional).
229233
Body:
230234
Empty.
231235
Response:
@@ -244,14 +248,33 @@ def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str
244248
backend,
245249
exclude_fields=["endorsement", "sk"],
246250
)
247-
backend.delete_comment(comment_id)
248251
author_id = comment["author_id"]
249252
comment_course_id = comment["course_id"]
250-
parent_comment_id = data["parent_id"]
251-
if parent_comment_id:
252-
backend.update_stats_for_course(author_id, comment_course_id, replies=-1)
253+
254+
# soft_delete_comment returns (responses_deleted, replies_deleted)
255+
responses_deleted, replies_deleted = backend.soft_delete_comment(
256+
comment_id, deleted_by
257+
)
258+
259+
# Update stats based on what was actually deleted
260+
if responses_deleted > 0:
261+
# A response (parent comment) was deleted
262+
backend.update_stats_for_course(
263+
author_id,
264+
comment_course_id,
265+
responses=-responses_deleted,
266+
deleted_responses=responses_deleted,
267+
replies=-replies_deleted,
268+
deleted_replies=replies_deleted,
269+
)
253270
else:
254-
backend.update_stats_for_course(author_id, comment_course_id, responses=-1)
271+
# Only a reply was deleted (no response)
272+
backend.update_stats_for_course(
273+
author_id,
274+
comment_course_id,
275+
replies=-replies_deleted,
276+
deleted_replies=replies_deleted,
277+
)
255278
return data
256279

257280

@@ -388,3 +411,64 @@ def get_user_comments(
388411
"num_pages": num_pages,
389412
"page": page,
390413
}
414+
415+
416+
def get_deleted_comments_for_course(
417+
course_id: str, page: int = 1, per_page: int = 20, author_id: Optional[str] = None
418+
) -> dict[str, Any]:
419+
"""
420+
Get deleted comments for a specific course.
421+
422+
Args:
423+
course_id (str): The course identifier
424+
page (int): Page number for pagination (default: 1)
425+
per_page (int): Number of comments per page (default: 20)
426+
author_id (str, optional): Filter by author ID
427+
428+
Returns:
429+
dict: Dictionary containing deleted comments and pagination info
430+
"""
431+
backend = get_backend(course_id)()
432+
return backend.get_deleted_comments_for_course(course_id, page, per_page, author_id)
433+
434+
435+
def restore_comment(
436+
comment_id: str, course_id: Optional[str] = None, restored_by: Optional[str] = None
437+
) -> bool:
438+
"""
439+
Restore a soft-deleted comment.
440+
441+
Args:
442+
comment_id (str): The ID of the comment to restore
443+
course_id (str, optional): The course ID for backend selection
444+
restored_by (str, optional): The ID of the user performing the restoration
445+
446+
Returns:
447+
bool: True if comment was restored, False if not found
448+
"""
449+
backend = get_backend(course_id)()
450+
return backend.restore_comment(comment_id, restored_by=restored_by)
451+
452+
453+
def restore_user_deleted_comments(
454+
user_id: str,
455+
course_ids: list[str],
456+
course_id: Optional[str] = None,
457+
restored_by: Optional[str] = None,
458+
) -> int:
459+
"""
460+
Restore all deleted comments for a user across courses.
461+
462+
Args:
463+
user_id (str): The ID of the user whose comments to restore
464+
course_ids (list): List of course IDs to restore comments in
465+
course_id (str, optional): Course ID for backend selection (uses first from list if not provided)
466+
restored_by (str, optional): The ID of the user performing the restoration
467+
468+
Returns:
469+
int: Number of comments restored
470+
"""
471+
backend = get_backend(course_id or course_ids[0])()
472+
return backend.restore_user_deleted_comments(
473+
user_id, course_ids, restored_by=restored_by
474+
)

forum/api/search.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def search_threads(
7575
page: int = FORUM_DEFAULT_PAGE,
7676
per_page: int = FORUM_DEFAULT_PER_PAGE,
7777
is_moderator: bool = False,
78+
is_deleted: bool = False,
7879
) -> dict[str, Any]:
7980
"""
8081
Search for threads based on the provided data.
@@ -107,6 +108,7 @@ def search_threads(
107108
raw_query=False,
108109
commentable_ids=commentable_ids,
109110
is_moderator=is_moderator,
111+
is_deleted=is_deleted,
110112
)
111113

112114
if collections := data.get("collection"):

forum/api/threads.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,16 @@ def get_thread(
159159
raise ForumV2RequestError("Failed to prepare thread API response") from error
160160

161161

162-
def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str, Any]:
162+
def delete_thread(
163+
thread_id: str, course_id: Optional[str] = None, deleted_by: Optional[str] = None
164+
) -> dict[str, Any]:
163165
"""
164166
Delete the thread for the given thread_id.
165167
166168
Parameters:
167169
thread_id: The ID of the thread to be deleted.
170+
course_id: The ID of the course (optional).
171+
deleted_by: The ID of the user performing the delete (optional).
168172
Response:
169173
The details of the thread that is deleted.
170174
"""
@@ -177,7 +181,9 @@ def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str,
177181
f"Thread does not exist with Id: {thread_id}"
178182
) from exc
179183

180-
backend.delete_comments_of_a_thread(thread_id)
184+
count_of_response_deleted, count_of_replies_deleted = (
185+
backend.soft_delete_comments_of_a_thread(thread_id, deleted_by)
186+
)
181187
thread = backend.validate_object("CommentThread", thread_id)
182188

183189
try:
@@ -187,10 +193,17 @@ def delete_thread(thread_id: str, course_id: Optional[str] = None) -> dict[str,
187193
raise ForumV2RequestError("Failed to prepare thread API response") from error
188194

189195
backend.delete_subscriptions_of_a_thread(thread_id)
190-
result = backend.delete_thread(thread_id)
196+
result = backend.soft_delete_thread(thread_id, deleted_by)
191197
if result and not (thread["anonymous"] or thread["anonymous_to_peers"]):
192198
backend.update_stats_for_course(
193-
thread["author_id"], thread["course_id"], threads=-1
199+
thread["author_id"],
200+
thread["course_id"],
201+
threads=-1,
202+
responses=-count_of_response_deleted,
203+
replies=-count_of_replies_deleted,
204+
deleted_threads=1,
205+
deleted_responses=count_of_response_deleted,
206+
deleted_replies=count_of_replies_deleted,
194207
)
195208

196209
return serialized_data
@@ -393,6 +406,7 @@ def get_user_threads(
393406
"user_id": user_id,
394407
"group_id": group_id,
395408
"group_ids": group_ids,
409+
"is_deleted": kwargs.get("is_deleted", False),
396410
"context": kwargs.get("context"),
397411
}
398412
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:
420434
or MySQLBackend.get_course_id_by_thread_id(thread_id)
421435
or None
422436
)
437+
438+
439+
def get_deleted_threads_for_course(
440+
course_id: str, page: int = 1, per_page: int = 20, author_id: Optional[str] = None
441+
) -> dict[str, Any]:
442+
"""
443+
Get deleted threads for a specific course.
444+
445+
Args:
446+
course_id (str): The course identifier
447+
page (int): Page number for pagination (default: 1)
448+
per_page (int): Number of threads per page (default: 20)
449+
author_id (str, optional): Filter by author ID
450+
451+
Returns:
452+
dict: Dictionary containing deleted threads and pagination info
453+
"""
454+
backend = get_backend(course_id)()
455+
return backend.get_deleted_threads_for_course(course_id, page, per_page, author_id)
456+
457+
458+
def restore_thread(
459+
thread_id: str, course_id: Optional[str] = None, restored_by: Optional[str] = None
460+
) -> bool:
461+
"""
462+
Restore a soft-deleted thread.
463+
464+
Args:
465+
thread_id (str): The ID of the thread to restore
466+
course_id (str, optional): The course ID for backend selection
467+
restored_by (str, optional): The ID of the user performing the restoration
468+
469+
Returns:
470+
bool: True if thread was restored, False if not found
471+
"""
472+
backend = get_backend(course_id)()
473+
return backend.restore_thread(thread_id, restored_by=restored_by)
474+
475+
476+
def restore_user_deleted_threads(
477+
user_id: str,
478+
course_ids: list[str],
479+
course_id: Optional[str] = None,
480+
restored_by: Optional[str] = None,
481+
) -> int:
482+
"""
483+
Restore all deleted threads for a user across courses.
484+
485+
Args:
486+
user_id (str): The ID of the user whose threads to restore
487+
course_ids (list): List of course IDs to restore threads in
488+
course_id (str, optional): Course ID for backend selection (uses first from list if not provided)
489+
restored_by (str, optional): The ID of the user performing the restoration
490+
491+
Returns:
492+
int: Number of threads restored
493+
"""
494+
backend = get_backend(course_id or course_ids[0])()
495+
return backend.restore_user_deleted_threads(
496+
user_id, course_ids, restored_by=restored_by
497+
)

forum/api/users.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def get_user_active_threads(
198198
per_page: Optional[int] = FORUM_DEFAULT_PER_PAGE,
199199
group_id: Optional[str] = None,
200200
is_moderator: Optional[bool] = False,
201+
show_deleted: Optional[bool] = False,
201202
) -> dict[str, Any]:
202203
"""Get user active threads."""
203204
backend = get_backend(course_id)()
@@ -251,6 +252,7 @@ def get_user_active_threads(
251252
"context": "course",
252253
"raw_query": raw_query,
253254
"is_moderator": is_moderator,
255+
"is_deleted": show_deleted,
254256
}
255257
data = backend.handle_threads_query(**params)
256258

forum/backends/backend.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,27 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]:
476476
Retrieve all threads and comments authored by a specific user.
477477
"""
478478
raise NotImplementedError
479+
480+
@staticmethod
481+
def get_deleted_threads_for_course(
482+
course_id: str,
483+
page: int = 1,
484+
per_page: int = 20,
485+
author_id: Optional[str] = None,
486+
) -> dict[str, Any]:
487+
"""
488+
Get deleted threads for a specific course.
489+
"""
490+
raise NotImplementedError
491+
492+
@staticmethod
493+
def get_deleted_comments_for_course(
494+
course_id: str,
495+
page: int = 1,
496+
per_page: int = 20,
497+
author_id: Optional[str] = None,
498+
) -> dict[str, Any]:
499+
"""
500+
Get deleted comments for a specific course.
501+
"""
502+
raise NotImplementedError

0 commit comments

Comments
 (0)