From 817e53beb62389fd2fbc74c7d08274f62b8c99dd Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Thu, 23 Apr 2026 11:22:31 +0500 Subject: [PATCH] feat: Add MySQL support for bulk APIs --- forum/__init__.py | 2 +- forum/api/__init__.py | 4 ++ forum/api/users.py | 18 ++++++ forum/backends/backend.py | 10 +++ forum/backends/mongodb/api.py | 28 +++++++++ forum/backends/mysql/api.py | 24 ++++++++ forum/urls.py | 6 ++ forum/views/users.py | 34 ++++++++++ tests/test_views/test_users.py | 109 +++++++++++++++++++++++++++++++++ 9 files changed, 234 insertions(+), 1 deletion(-) diff --git a/forum/__init__.py b/forum/__init__.py index 255a215f..1257036d 100644 --- a/forum/__init__.py +++ b/forum/__init__.py @@ -2,4 +2,4 @@ Openedx forum app. """ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/forum/api/__init__.py b/forum/api/__init__.py index 93c0dad7..4d50155a 100644 --- a/forum/api/__init__.py +++ b/forum/api/__init__.py @@ -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, @@ -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", @@ -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", diff --git a/forum/api/users.py b/forum/api/users.py index 71c3a36e..8355f8d3 100644 --- a/forum/api/users.py +++ b/forum/api/users.py @@ -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) diff --git a/forum/backends/backend.py b/forum/backends/backend.py index 8a5b9175..2f7a4081 100644 --- a/forum/backends/backend.py +++ b/forum/backends/backend.py @@ -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 diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index 4c3dd78b..01124532 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -1765,3 +1765,31 @@ def get_user_contents_by_username(username: str) -> list[dict[str, Any]]: CommentThread().find({"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.""" + 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} diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 9120ac15..da55a552 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -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} diff --git a/forum/urls.py b/forum/urls.py index ee23f70e..6739272c 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -16,6 +16,7 @@ ) from forum.views.threads import CreateThreadAPIView, ThreadsAPIView, UserThreadsAPIView from forum.views.users import ( + BulkDeleteUserPostsAPIView, UserActiveThreadsAPIView, UserAPIView, UserCourseStatsAPIView, @@ -151,6 +152,11 @@ UserRetireAPIView.as_view(), name="user-retire", ), + path( + "users//posts", + BulkDeleteUserPostsAPIView.as_view(), + name="user-posts", + ), # Proxy view for various API endpoints # Uncomment to redirect remaining API calls to the V1 API. # path( diff --git a/forum/views/users.py b/forum/views/users.py index 1a8f94a4..c148d58d 100644 --- a/forum/views/users.py +++ b/forum/views/users.py @@ -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, @@ -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.""" diff --git a/tests/test_views/test_users.py b/tests/test_views/test_users.py index 732e7216..9cdfaf62 100644 --- a/tests/test_views/test_users.py +++ b/tests/test_views/test_users.py @@ -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//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//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//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