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.4.1"
__version__ = "0.4.2"
4 changes: 4 additions & 0 deletions forum/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
)
from .users import (
create_user,
delete_user_posts,
get_user,
get_user_active_threads,
get_user_course_stats,
get_user_post_counts,
mark_thread_as_read,
retire_user,
update_user,
Expand All @@ -61,6 +63,7 @@
"delete_subscription",
"delete_thread",
"delete_thread_vote",
"delete_user_posts",
"get_commentables_stats",
"get_course_id_by_comment",
"get_course_id_by_thread",
Expand All @@ -71,6 +74,7 @@
"get_user_active_threads",
"get_user_comments",
"get_user_course_stats",
"get_user_post_counts",
"get_user_subscriptions",
"get_user_threads",
"mark_thread_as_read",
Expand Down
18 changes: 18 additions & 0 deletions forum/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,21 @@ def update_users_in_course(course_id: str) -> dict[str, int]:
backend = get_backend(course_id)()
updated_users = backend.update_all_users_in_course(course_id)
return {"user_count": len(updated_users)}


def get_user_post_counts(user_id: str, course_id: str) -> dict[str, int]:
"""Get count of threads and comments authored by user in course."""
backend = get_backend(course_id)()
user = backend.get_user(user_id)
if not user:
raise ForumV2RequestError(f"user not found with id: {user_id}")
return backend.get_user_post_counts(user_id, course_id)


def delete_user_posts(user_id: str, course_id: str) -> dict[str, int]:
"""Delete all threads and comments authored by user in course. Returns counts before deletion."""
backend = get_backend(course_id)()
user = backend.get_user(user_id)
if not user:
raise ForumV2RequestError(f"user not found with id: {user_id}")
return backend.delete_user_posts(user_id, course_id)
10 changes: 10 additions & 0 deletions forum/backends/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,13 @@ 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_user_post_counts(user_id: str, course_id: str) -> dict[str, int]:
"""Return thread_count and comment_count for user in course."""
raise NotImplementedError

@staticmethod
def delete_user_posts(user_id: str, course_id: str) -> dict[str, int]:
"""Delete all threads and comments by user in course. Returns counts before deletion."""
raise NotImplementedError
28 changes: 28 additions & 0 deletions forum/backends/mongodb/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1765,3 +1765,31 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]:
CommentThread().find({"author_username": username})
)
return contents

Comment thread
taimoor-ahmed-1 marked this conversation as resolved.
@staticmethod
def get_user_post_counts(user_id: str, course_id: str) -> dict[str, int]:
"""Return thread_count and comment_count for user in course."""
query = {"author_id": user_id, "course_id": course_id}
thread_count = CommentThread().count_documents(
{**query, "_type": "CommentThread"}
)
comment_count = Comment().count_documents({**query, "_type": "Comment"})
return {"thread_count": thread_count, "comment_count": comment_count}

@staticmethod
def delete_user_posts(user_id: str, course_id: str) -> dict[str, int]:
"""Delete all threads and comments by user in course. Returns counts before deletion."""
thread_model = CommentThread()
comment_model = Comment()
query = {"author_id": user_id, "course_id": course_id}
thread_count = thread_model.count_documents({**query, "_type": "CommentThread"})
comment_count = comment_model.count_documents({**query, "_type": "Comment"})
# Collect IDs before deleting to avoid cursor invalidation.
# find() uses override_query which adds _type automatically.
comment_ids = [str(c["_id"]) for c in comment_model.find(query)]
for comment_id in comment_ids:
comment_model.delete(comment_id)
thread_ids = [str(t["_id"]) for t in thread_model.find(query)]
for thread_id in thread_ids:
thread_model.delete(thread_id)
return {"thread_count": thread_count, "comment_count": comment_count}
24 changes: 24 additions & 0 deletions forum/backends/mysql/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2308,3 +2308,27 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]:
for thread in CommentThread.objects.filter(author__username=username)
]
return contents

@staticmethod
def get_user_post_counts(user_id: str, course_id: str) -> dict[str, int]:
"""Return thread_count and comment_count for user in course."""
thread_count = CommentThread.objects.filter(
author_id=user_id, course_id=course_id
).count()
comment_count = Comment.objects.filter(
author_id=user_id, course_id=course_id
).count()
return {"thread_count": thread_count, "comment_count": comment_count}

@staticmethod
def delete_user_posts(user_id: str, course_id: str) -> dict[str, int]:
"""Delete all threads and comments by user in course. Returns counts before deletion."""
thread_count = CommentThread.objects.filter(
author_id=user_id, course_id=course_id
).count()
comment_count = Comment.objects.filter(
author_id=user_id, course_id=course_id
).count()
Comment.objects.filter(author_id=user_id, course_id=course_id).delete()
CommentThread.objects.filter(author_id=user_id, course_id=course_id).delete()
return {"thread_count": thread_count, "comment_count": comment_count}
6 changes: 6 additions & 0 deletions forum/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from forum.views.threads import CreateThreadAPIView, ThreadsAPIView, UserThreadsAPIView
from forum.views.users import (
BulkDeleteUserPostsAPIView,
UserActiveThreadsAPIView,
UserAPIView,
UserCourseStatsAPIView,
Expand Down Expand Up @@ -151,6 +152,11 @@
UserRetireAPIView.as_view(),
name="user-retire",
),
path(
"users/<str:user_id>/posts",
BulkDeleteUserPostsAPIView.as_view(),
name="user-posts",
),
# Proxy view for various API endpoints
# Uncomment to redirect remaining API calls to the V1 API.
# path(
Expand Down
34 changes: 34 additions & 0 deletions forum/views/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
from forum.api import get_user
from forum.api.users import (
create_user,
delete_user_posts,
get_user_active_threads,
get_user_course_stats,
get_user_post_counts,
mark_thread_as_read,
retire_user,
update_user,
Expand Down Expand Up @@ -201,6 +203,38 @@ def get(self, request: Request, user_id: str) -> Response:
return Response(serialized_data)


class BulkDeleteUserPostsAPIView(APIView):
"""Bulk count/delete user posts in a course."""

permission_classes = (AllowAny,)

def get(self, request: Request, user_id: str) -> Response:
"""Return thread_count and comment_count for user in course."""
course_id = request.query_params.get("course_id")
if not course_id:
return Response(
{"error": "course_id is required"}, status=status.HTTP_400_BAD_REQUEST
)
try:
data = get_user_post_counts(user_id, course_id)
except ForumV2RequestError as e:
return Response({"error": str(e)}, status=status.HTTP_404_NOT_FOUND)
return Response(data, status=status.HTTP_200_OK)

def delete(self, request: Request, user_id: str) -> Response:
"""Delete all posts by user in course. Returns counts before deletion."""
course_id = request.query_params.get("course_id")
if not course_id:
return Response(
{"error": "course_id is required"}, status=status.HTTP_400_BAD_REQUEST
)
try:
data = delete_user_posts(user_id, course_id)
except ForumV2RequestError as e:
return Response({"error": str(e)}, status=status.HTTP_404_NOT_FOUND)
return Response(data, status=status.HTTP_200_OK)


class UserCourseStatsAPIView(APIView):
"""User Course stats API."""

Expand Down
109 changes: 109 additions & 0 deletions tests/test_views/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,3 +507,112 @@ def test_retire_user_with_subscribed_threads(
assert content["title"] == RETIRED_TITLE
assert content["body"] == RETIRED_BODY
assert content["author_username"] == retired_username


def test_get_user_post_counts(api_client: APIClient, patched_get_backend: Any) -> None:
"""Test getting thread and comment counts for a user in a course."""
backend = patched_get_backend
user_id = backend.generate_id()
username = "test-user"
backend.find_or_create_user(user_id, username)
course_id = "course1"
# Create 3 threads and 1 comment per thread (3 comments total).
for i in range(3):
thread_id = backend.create_thread(
{
"title": f"Thread {i}",
"body": "body",
"course_id": course_id,
"commentable_id": "commentable1",
"author_id": user_id,
"author_username": username,
}
)
backend.create_comment(
{
"body": "comment body",
"course_id": course_id,
"author_id": user_id,
"comment_thread_id": str(thread_id),
"author_username": username,
}
)
response = api_client.get(f"/api/v2/users/{user_id}/posts?course_id={course_id}")
assert response.status_code == 200
data = response.json()
assert data["thread_count"] == 3
assert data["comment_count"] == 3


def test_get_user_post_counts_missing_course_id(
api_client: APIClient, patched_get_backend: Any
) -> None:
"""Test that GET /users/<user_id>/posts returns 400 when course_id is missing."""
backend = patched_get_backend
user_id = backend.generate_id()
backend.find_or_create_user(user_id, "test-user")
response = api_client.get(f"/api/v2/users/{user_id}/posts")
assert response.status_code == 400


def test_get_user_post_counts_invalid_user(
api_client: APIClient, patched_get_backend: Any
) -> None:
"""Test that GET /users/<user_id>/posts returns 404 for unknown user."""
backend = patched_get_backend
assert backend.get_user("999999") is None
response = api_client.get("/api/v2/users/999999/posts?course_id=course1")
assert response.status_code == 404


def test_delete_user_posts(api_client: APIClient, patched_get_backend: Any) -> None:
"""Test deleting all threads and comments by a user in a course."""
backend = patched_get_backend
user_id = backend.generate_id()
username = "test-user"
backend.find_or_create_user(user_id, username)
course_id = "course1"
thread_ids = []
for i in range(2):
thread_id = backend.create_thread(
{
"title": f"Thread {i}",
"body": "body",
"course_id": course_id,
"commentable_id": "commentable1",
"author_id": user_id,
"author_username": username,
}
)
backend.create_comment(
{
"body": "comment body",
"course_id": course_id,
"author_id": user_id,
"comment_thread_id": str(thread_id),
"author_username": username,
}
)
thread_ids.append(thread_id)
response = api_client.delete_json(
f"/api/v2/users/{user_id}/posts?course_id={course_id}"
)
assert response.status_code == 200
data = response.json()
assert data["thread_count"] == 2
assert data["comment_count"] == 2
# Verify content is gone.
counts = api_client.get(f"/api/v2/users/{user_id}/posts?course_id={course_id}")
assert counts.json()["thread_count"] == 0
assert counts.json()["comment_count"] == 0


def test_delete_user_posts_missing_course_id(
api_client: APIClient, patched_get_backend: Any
) -> None:
"""Test that DELETE /users/<user_id>/posts returns 400 when course_id is missing."""
backend = patched_get_backend
user_id = backend.generate_id()
backend.find_or_create_user(user_id, "test-user")
response = api_client.delete_json(f"/api/v2/users/{user_id}/posts")
assert response.status_code == 400
Loading