openedx-forum 0.4.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. forum/__init__.py +9 -0
  2. forum/admin.py +154 -0
  3. forum/api/__init__.py +94 -0
  4. forum/api/commentables.py +22 -0
  5. forum/api/comments.py +366 -0
  6. forum/api/flags.py +121 -0
  7. forum/api/pins.py +68 -0
  8. forum/api/search.py +129 -0
  9. forum/api/subscriptions.py +147 -0
  10. forum/api/threads.py +407 -0
  11. forum/api/users.py +383 -0
  12. forum/api/votes.py +257 -0
  13. forum/apps.py +36 -0
  14. forum/backend.py +10 -0
  15. forum/backends/__init__.py +1 -0
  16. forum/backends/backend.py +488 -0
  17. forum/backends/mysql/__init__.py +5 -0
  18. forum/backends/mysql/api.py +2332 -0
  19. forum/backends/mysql/models.py +776 -0
  20. forum/conf/locale/config.yaml +85 -0
  21. forum/constants.py +11 -0
  22. forum/handlers.py +141 -0
  23. forum/management/__init__.py +0 -0
  24. forum/management/commands/__init__.py +0 -0
  25. forum/management/commands/delete_unused_forum_indices.py +29 -0
  26. forum/management/commands/forum_delete_course_from_mongodb.py +52 -0
  27. forum/management/commands/forum_migrate_course_from_mongodb_to_mysql.py +42 -0
  28. forum/management/commands/initialize_forum_indices.py +45 -0
  29. forum/management/commands/rebuild_forum_indices.py +59 -0
  30. forum/management/commands/validate_forum_indices.py +26 -0
  31. forum/migration_helpers.py +480 -0
  32. forum/migrations/0001_initial.py +631 -0
  33. forum/migrations/0002_alter_readstate_course_id_and_more.py +48 -0
  34. forum/migrations/0003_alter_commentthread_title.py +18 -0
  35. forum/migrations/0004_add_author_username_fields.py +43 -0
  36. forum/migrations/0005_alter_commentthread_pinned.py +26 -0
  37. forum/migrations/__init__.py +0 -0
  38. forum/models.py +4 -0
  39. forum/mongo.py +34 -0
  40. forum/pagination.py +42 -0
  41. forum/search/__init__.py +45 -0
  42. forum/search/base.py +118 -0
  43. forum/search/es.py +699 -0
  44. forum/search/meilisearch.py +233 -0
  45. forum/search/typesense.py +460 -0
  46. forum/serializers/__init__.py +0 -0
  47. forum/serializers/comment.py +112 -0
  48. forum/serializers/contents.py +89 -0
  49. forum/serializers/custom_datetime.py +26 -0
  50. forum/serializers/subscriptions.py +26 -0
  51. forum/serializers/thread.py +295 -0
  52. forum/serializers/users.py +35 -0
  53. forum/serializers/votes.py +86 -0
  54. forum/settings/__init__.py +0 -0
  55. forum/settings/common.py +52 -0
  56. forum/settings/devstack.py +17 -0
  57. forum/settings/production.py +17 -0
  58. forum/settings/test.py +85 -0
  59. forum/signals.py +23 -0
  60. forum/templates/forum/base.html +26 -0
  61. forum/urls.py +174 -0
  62. forum/utils.py +264 -0
  63. forum/views/__init__.py +0 -0
  64. forum/views/commentables.py +35 -0
  65. forum/views/comments.py +209 -0
  66. forum/views/flags.py +76 -0
  67. forum/views/pins.py +75 -0
  68. forum/views/proxy.py +47 -0
  69. forum/views/search.py +99 -0
  70. forum/views/subscriptions.py +133 -0
  71. forum/views/threads.py +167 -0
  72. forum/views/users.py +262 -0
  73. forum/views/votes.py +150 -0
  74. openedx_forum-0.4.7.dist-info/METADATA +228 -0
  75. openedx_forum-0.4.7.dist-info/RECORD +79 -0
  76. openedx_forum-0.4.7.dist-info/WHEEL +5 -0
  77. openedx_forum-0.4.7.dist-info/entry_points.txt +2 -0
  78. openedx_forum-0.4.7.dist-info/licenses/LICENSE.txt +664 -0
  79. openedx_forum-0.4.7.dist-info/top_level.txt +1 -0
forum/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Openedx forum app.
3
+ """
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("forum")
8
+ except PackageNotFoundError: # pragma: no cover
9
+ __version__ = "unknown"
forum/admin.py ADDED
@@ -0,0 +1,154 @@
1
+ """Admin module for forum."""
2
+
3
+ from django.contrib import admin
4
+ from forum.models import (
5
+ ForumUser,
6
+ CourseStat,
7
+ CommentThread,
8
+ Comment,
9
+ EditHistory,
10
+ AbuseFlagger,
11
+ HistoricalAbuseFlagger,
12
+ ReadState,
13
+ LastReadTime,
14
+ UserVote,
15
+ Subscription,
16
+ MongoContent,
17
+ )
18
+
19
+
20
+ @admin.register(ForumUser)
21
+ class ForumUserAdmin(admin.ModelAdmin): # type: ignore
22
+ """Admin interface for ForumUser model."""
23
+
24
+ list_display = ("user", "default_sort_key")
25
+ search_fields = ("user__username", "user__email")
26
+
27
+
28
+ @admin.register(CourseStat)
29
+ class CourseStatAdmin(admin.ModelAdmin): # type: ignore
30
+ """Admin interface for CourseStat model."""
31
+
32
+ list_display = (
33
+ "user",
34
+ "course_id",
35
+ "active_flags",
36
+ "inactive_flags",
37
+ "threads",
38
+ "responses",
39
+ "replies",
40
+ "last_activity_at",
41
+ )
42
+ search_fields = ("user__username", "course_id")
43
+ list_filter = ("course_id",)
44
+
45
+
46
+ @admin.register(CommentThread)
47
+ class CommentThreadAdmin(admin.ModelAdmin): # type: ignore
48
+ """Admin interface for CommentThread model."""
49
+
50
+ list_display = (
51
+ "title",
52
+ "author",
53
+ "course_id",
54
+ "thread_type",
55
+ "context",
56
+ "closed",
57
+ "pinned",
58
+ "created_at",
59
+ "updated_at",
60
+ )
61
+ search_fields = ("title", "body", "author__username", "course_id")
62
+ list_filter = ("thread_type", "context", "closed", "pinned")
63
+
64
+
65
+ @admin.register(Comment)
66
+ class CommentAdmin(admin.ModelAdmin): # type: ignore
67
+ """Admin interface for Comment model."""
68
+
69
+ list_display = (
70
+ "comment_thread",
71
+ "author",
72
+ "body",
73
+ "created_at",
74
+ "updated_at",
75
+ "endorsed",
76
+ "anonymous",
77
+ )
78
+ search_fields = ("body", "author__username", "comment_thread__title")
79
+ list_filter = ("endorsed", "anonymous")
80
+
81
+
82
+ @admin.register(EditHistory)
83
+ class EditHistoryAdmin(admin.ModelAdmin): # type: ignore
84
+ """Admin interface for EditHistory model."""
85
+
86
+ list_display = ("editor", "content_object_id", "created_at", "reason_code")
87
+ search_fields = ("editor__username", "original_body")
88
+ list_filter = ("reason_code",)
89
+
90
+
91
+ @admin.register(AbuseFlagger)
92
+ class AbuseFlaggerAdmin(admin.ModelAdmin): # type: ignore
93
+ """Admin interface for AbuseFlagger model."""
94
+
95
+ list_display = ("user", "content_object_id", "flagged_at")
96
+ search_fields = ("user__username",)
97
+ list_filter = ("content_type",)
98
+
99
+
100
+ @admin.register(HistoricalAbuseFlagger)
101
+ class HistoricalAbuseFlaggerAdmin(admin.ModelAdmin): # type: ignore
102
+ """Admin interface for HistoricalAbuseFlagger model."""
103
+
104
+ list_display = ("user", "content_object_id", "flagged_at")
105
+ search_fields = ("user__username",)
106
+ list_filter = ("content_type",)
107
+
108
+
109
+ @admin.register(ReadState)
110
+ class ReadStateAdmin(admin.ModelAdmin): # type: ignore
111
+ """Admin interface for ReadState model."""
112
+
113
+ list_display = ("user", "course_id")
114
+ search_fields = ("user__username", "course_id")
115
+
116
+
117
+ @admin.register(LastReadTime)
118
+ class LastReadTimeAdmin(admin.ModelAdmin): # type: ignore
119
+ """Admin interface for LastReadTime model."""
120
+
121
+ list_display = ("read_state", "comment_thread", "timestamp")
122
+ search_fields = ("read_state__user__username", "comment_thread__title")
123
+
124
+
125
+ @admin.register(UserVote)
126
+ class UserVoteAdmin(admin.ModelAdmin): # type: ignore
127
+ """Admin interface for UserVote model."""
128
+
129
+ list_display = ("user", "content_object_id", "vote")
130
+ search_fields = ("user__username",)
131
+ list_filter = ("vote",)
132
+
133
+
134
+ @admin.register(Subscription)
135
+ class SubscriptionAdmin(admin.ModelAdmin): # type: ignore
136
+ """Admin interface for Subscription model."""
137
+
138
+ list_display = (
139
+ "subscriber",
140
+ "source_object_id",
141
+ "source_content_type",
142
+ "created_at",
143
+ "updated_at",
144
+ )
145
+ search_fields = ("subscriber__username",)
146
+ list_filter = ("source_content_type",)
147
+
148
+
149
+ @admin.register(MongoContent)
150
+ class MongoContentAdmin(admin.ModelAdmin): # type: ignore
151
+ """Admin interface for MongoContent model."""
152
+
153
+ list_display = ("mongo_id", "content_object_id", "content_type")
154
+ search_fields = ("mongo_id",)
forum/api/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Native Python APIs.
3
+ """
4
+
5
+ from .commentables import get_commentables_stats
6
+ from .comments import (
7
+ create_child_comment,
8
+ create_parent_comment,
9
+ delete_comment,
10
+ get_course_id_by_comment,
11
+ get_parent_comment,
12
+ get_user_comments,
13
+ update_comment,
14
+ )
15
+ from .flags import (
16
+ update_comment_flag,
17
+ update_thread_flag,
18
+ )
19
+ from .pins import pin_thread, unpin_thread
20
+ from .search import search_threads
21
+ from .subscriptions import (
22
+ create_subscription,
23
+ delete_subscription,
24
+ get_thread_subscriptions,
25
+ get_user_subscriptions,
26
+ )
27
+ from .threads import (
28
+ create_thread,
29
+ delete_thread,
30
+ get_course_id_by_thread,
31
+ get_thread,
32
+ get_user_threads,
33
+ update_thread,
34
+ )
35
+ from .users import (
36
+ create_user,
37
+ delete_user_posts,
38
+ get_user,
39
+ get_user_active_threads,
40
+ get_user_course_stats,
41
+ get_user_post_counts,
42
+ mark_thread_as_read,
43
+ retire_user,
44
+ update_user,
45
+ update_username,
46
+ update_users_in_course,
47
+ )
48
+ from .votes import (
49
+ delete_comment_vote,
50
+ delete_thread_vote,
51
+ update_comment_votes,
52
+ update_thread_votes,
53
+ )
54
+
55
+ __all__ = [
56
+ "create_child_comment",
57
+ "create_parent_comment",
58
+ "create_subscription",
59
+ "create_thread",
60
+ "create_user",
61
+ "delete_comment",
62
+ "delete_comment_vote",
63
+ "delete_subscription",
64
+ "delete_thread",
65
+ "delete_thread_vote",
66
+ "delete_user_posts",
67
+ "get_commentables_stats",
68
+ "get_course_id_by_comment",
69
+ "get_course_id_by_thread",
70
+ "get_parent_comment",
71
+ "get_thread",
72
+ "get_thread_subscriptions",
73
+ "get_user",
74
+ "get_user_active_threads",
75
+ "get_user_comments",
76
+ "get_user_course_stats",
77
+ "get_user_post_counts",
78
+ "get_user_subscriptions",
79
+ "get_user_threads",
80
+ "mark_thread_as_read",
81
+ "pin_thread",
82
+ "retire_user",
83
+ "search_threads",
84
+ "unpin_thread",
85
+ "update_comment",
86
+ "update_comment_flag",
87
+ "update_comment_votes",
88
+ "update_thread",
89
+ "update_thread_flag",
90
+ "update_thread_votes",
91
+ "update_user",
92
+ "update_username",
93
+ "update_users_in_course",
94
+ ]
@@ -0,0 +1,22 @@
1
+ """
2
+ Native Python Commenttables APIs.
3
+ """
4
+
5
+ from forum.backend import get_backend
6
+
7
+
8
+ def get_commentables_stats(course_id: str) -> dict[str, int]:
9
+ """
10
+ Get the threads count based on thread_type and group them by commentable_id.
11
+
12
+ Parameters:
13
+ course_id: The ID of the course.
14
+ Body:
15
+ Empty.
16
+ Response:
17
+ The threads count for the given course_id based on thread_type.
18
+ e.g.
19
+ reponse = {'course': {'discussion': 1, 'question': 1}}
20
+ """
21
+ backend = get_backend(course_id)()
22
+ return backend.get_commentables_counts_based_on_type(course_id)
forum/api/comments.py ADDED
@@ -0,0 +1,366 @@
1
+ """
2
+ Native Python Comments APIs.
3
+ """
4
+
5
+ import logging
6
+ import math
7
+ from typing import Any, Optional
8
+
9
+ from django.core.exceptions import ObjectDoesNotExist
10
+ from rest_framework.serializers import ValidationError
11
+
12
+ from forum.backend import get_backend
13
+ from forum.backends.mysql.api import MySQLBackend
14
+ from forum.serializers.comment import CommentSerializer
15
+ from forum.utils import ForumV2RequestError
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def prepare_comment_api_response(
21
+ comment: dict[str, Any],
22
+ backend: Any,
23
+ exclude_fields: Optional[list[str]] = None,
24
+ ) -> dict[str, Any]:
25
+ """
26
+ Return serialized validated data.
27
+
28
+ Parameters:
29
+ comment: The comment details that needs to be serialized.
30
+ exclude_fields: Any fields that need to be excluded from response.
31
+
32
+ Response:
33
+ serialized validated data of the comment.
34
+ """
35
+ comment_data = {
36
+ **comment,
37
+ "id": str(comment.get("_id")),
38
+ "user_id": comment.get("author_id"),
39
+ "thread_id": str(comment.get("comment_thread_id")),
40
+ "username": comment.get("author_username"),
41
+ "parent_id": str(comment.get("parent_id")),
42
+ "type": str(comment.get("_type", "")).lower(),
43
+ }
44
+ if not exclude_fields:
45
+ exclude_fields = []
46
+ exclude_fields.append("children")
47
+ serializer = CommentSerializer(
48
+ data=comment_data,
49
+ exclude_fields=exclude_fields,
50
+ backend=backend,
51
+ )
52
+ if not serializer.is_valid(raise_exception=True):
53
+ raise ValidationError(serializer.errors)
54
+
55
+ return serializer.data
56
+
57
+
58
+ def get_parent_comment(
59
+ comment_id: str, course_id: Optional[str] = None
60
+ ) -> dict[str, Any]:
61
+ """
62
+ Get a parent comment.
63
+
64
+ Parameters:
65
+ comment_id: The ID of the comment.
66
+ Body:
67
+ Empty.
68
+ Response:
69
+ The details of the comment for the given comment_id.
70
+ """
71
+ backend = get_backend(course_id)()
72
+ try:
73
+ comment = backend.validate_object("Comment", comment_id)
74
+ except ObjectDoesNotExist as exc:
75
+ log.error("Forumv2RequestError for get parent comment request.")
76
+ raise ForumV2RequestError(
77
+ f"Comment does not exists with Id: {comment_id}"
78
+ ) from exc
79
+ return prepare_comment_api_response(
80
+ comment,
81
+ backend,
82
+ exclude_fields=["sk"],
83
+ )
84
+
85
+
86
+ def create_child_comment(
87
+ parent_comment_id: str,
88
+ body: str,
89
+ user_id: str,
90
+ course_id: str,
91
+ anonymous: bool,
92
+ anonymous_to_peers: bool,
93
+ ) -> dict[str, Any]:
94
+ """
95
+ Create a new child comment.
96
+
97
+ Parameters:
98
+ comment_id: The ID of the parent comment for creating it's child comment.
99
+ body: The content of the comment.
100
+ course_id: The Id of the respective course.
101
+ user_id: The requesting user id.
102
+ anonymous: anonymous flag(True or False).
103
+ anonymous_to_peers: anonymous to peers flag(True or False).
104
+ Response:
105
+ The details of the comment that is created.
106
+ """
107
+ backend = get_backend(course_id)()
108
+ try:
109
+ parent_comment = backend.validate_object("Comment", parent_comment_id)
110
+ except ObjectDoesNotExist as exc:
111
+ log.error("Forumv2RequestError for create child comment request.")
112
+ raise ForumV2RequestError(
113
+ f"Comment does not exists with Id: {parent_comment_id}"
114
+ ) from exc
115
+
116
+ comment_id = backend.create_comment(
117
+ {
118
+ "body": body,
119
+ "author_id": user_id,
120
+ "course_id": course_id,
121
+ "anonymous": anonymous,
122
+ "anonymous_to_peers": anonymous_to_peers,
123
+ "depth": 1,
124
+ "comment_thread_id": backend.get_thread_id_by_comment_id(parent_comment_id),
125
+ "parent_id": parent_comment_id,
126
+ }
127
+ )
128
+ comment = backend.get_comment(comment_id)
129
+ if not comment:
130
+ log.error("Forumv2RequestError for create child comment request.")
131
+ raise ForumV2RequestError("comment is not created")
132
+
133
+ user = backend.get_user(user_id)
134
+ thread = backend.get_thread(parent_comment["comment_thread_id"])
135
+ if user and thread and comment:
136
+ backend.mark_as_read(user_id, parent_comment["comment_thread_id"])
137
+ try:
138
+ comment_data = prepare_comment_api_response(
139
+ comment,
140
+ backend,
141
+ exclude_fields=["endorsement", "sk"],
142
+ )
143
+ return comment_data
144
+ except ValidationError as error:
145
+ raise error
146
+
147
+
148
+ def update_comment(
149
+ comment_id: str,
150
+ body: Optional[str] = None,
151
+ course_id: Optional[str] = None,
152
+ user_id: Optional[str] = None,
153
+ anonymous: Optional[bool] = None,
154
+ anonymous_to_peers: Optional[bool] = None,
155
+ endorsed: Optional[bool] = None,
156
+ closed: Optional[bool] = None,
157
+ editing_user_id: Optional[str] = None,
158
+ edit_reason_code: Optional[str] = None,
159
+ endorsement_user_id: Optional[str] = None,
160
+ ) -> dict[str, Any]:
161
+ """
162
+ Update an existing child/parent comment.
163
+
164
+ Parameters:
165
+ comment_id: The ID of the comment to be edited.
166
+ body (Optional[str]): The content of the comment.
167
+ course_id (Optional[str]): The Id of the respective course.
168
+ user_id (Optional[str]): The requesting user id.
169
+ anonymous (Optional[bool]): anonymous flag(True or False).
170
+ anonymous_to_peers (Optional[bool]): anonymous to peers flag(True or False).
171
+ endorsed (Optional[bool]): Flag indicating if the comment is endorsed by any user.
172
+ closed (Optional[bool]): Flag indicating if the comment thread is closed.
173
+ editing_user_id (Optional[str]): The ID of the user editing the comment.
174
+ edit_reason_code (Optional[str]): The reason for editing the comment, typically represented by a code.
175
+ endorsement_user_id (Optional[str]): The ID of the user endorsing the comment.
176
+ Response:
177
+ The details of the comment that is updated.
178
+ """
179
+ backend = get_backend(course_id)()
180
+ try:
181
+ backend.validate_object("Comment", comment_id)
182
+ except ObjectDoesNotExist as exc:
183
+ log.error("Forumv2RequestError for update comment request.")
184
+ raise ForumV2RequestError(
185
+ f"Comment does not exists with Id: {comment_id}"
186
+ ) from exc
187
+
188
+ updated_comment = backend.update_comment_and_get_updated_comment(
189
+ comment_id,
190
+ body=body,
191
+ user_id=user_id,
192
+ anonymous=anonymous,
193
+ anonymous_to_peers=anonymous_to_peers,
194
+ endorsed=endorsed,
195
+ closed=closed,
196
+ editing_user_id=editing_user_id,
197
+ edit_reason_code=edit_reason_code,
198
+ endorsement_user_id=endorsement_user_id,
199
+ )
200
+ if not updated_comment:
201
+ log.error("Forumv2RequestError for create child comment request.")
202
+ raise ForumV2RequestError("comment is not updated")
203
+ try:
204
+ return prepare_comment_api_response(
205
+ updated_comment,
206
+ backend,
207
+ exclude_fields=(
208
+ ["endorsement", "sk"] if updated_comment.get("parent_id") else ["sk"]
209
+ ),
210
+ )
211
+ except ValidationError as error:
212
+ raise error
213
+
214
+
215
+ def delete_comment(comment_id: str, course_id: Optional[str] = None) -> dict[str, Any]:
216
+ """
217
+ Delete a comment.
218
+
219
+ Parameters:
220
+ comment_id: The ID of the comment to be deleted.
221
+ Body:
222
+ Empty.
223
+ Response:
224
+ The details of the comment that is deleted.
225
+ """
226
+ backend = get_backend(course_id)()
227
+ try:
228
+ comment = backend.validate_object("Comment", comment_id)
229
+ except ObjectDoesNotExist as exc:
230
+ log.error("Forumv2RequestError for delete comment request.")
231
+ raise ForumV2RequestError(
232
+ f"Comment does not exists with Id: {comment_id}"
233
+ ) from exc
234
+ data = prepare_comment_api_response(
235
+ comment,
236
+ backend,
237
+ exclude_fields=["endorsement", "sk"],
238
+ )
239
+ backend.delete_comment(comment_id)
240
+ author_id = comment["author_id"]
241
+ comment_course_id = comment["course_id"]
242
+ parent_comment_id = data["parent_id"]
243
+ if parent_comment_id:
244
+ backend.update_stats_for_course(author_id, comment_course_id, replies=-1)
245
+ else:
246
+ backend.update_stats_for_course(author_id, comment_course_id, responses=-1)
247
+ return data
248
+
249
+
250
+ def create_parent_comment(
251
+ thread_id: str,
252
+ body: str,
253
+ user_id: str,
254
+ course_id: str,
255
+ anonymous: bool,
256
+ anonymous_to_peers: bool,
257
+ ) -> dict[str, Any]:
258
+ """
259
+ Create a new parent comment.
260
+
261
+ Parameters:
262
+ thread_id: The ID of the thread for creating a comment on it.
263
+ body: The content of the comment.
264
+ course_id: The Id of the respective course.
265
+ user_id: The requesting user id.
266
+ anonymous: anonymous flag(True or False).
267
+ anonymous_to_peers: anonymous to peers flag(True or False).
268
+ Response:
269
+ The details of the comment that is created.
270
+ """
271
+ backend = get_backend(course_id)()
272
+ try:
273
+ backend.validate_object("CommentThread", thread_id)
274
+ except ObjectDoesNotExist as exc:
275
+ log.error("Forumv2RequestError for create parent comment request.")
276
+ raise ForumV2RequestError(
277
+ f"Thread does not exists with Id: {thread_id}"
278
+ ) from exc
279
+
280
+ comment_id = backend.create_comment(
281
+ {
282
+ "body": body,
283
+ "author_id": user_id,
284
+ "course_id": course_id,
285
+ "anonymous": anonymous,
286
+ "anonymous_to_peers": anonymous_to_peers,
287
+ "depth": 0,
288
+ "comment_thread_id": thread_id,
289
+ }
290
+ )
291
+ if not comment_id:
292
+ log.error("Forumv2RequestError for create parent comment request.")
293
+ raise ForumV2RequestError("comment is not created")
294
+ comment = backend.get_comment(comment_id) or {}
295
+ user = backend.get_user(user_id)
296
+ if user and comment:
297
+ backend.mark_as_read(user_id, thread_id)
298
+ try:
299
+ return prepare_comment_api_response(
300
+ comment,
301
+ backend,
302
+ exclude_fields=["endorsement", "sk"],
303
+ )
304
+ except ValidationError as error:
305
+ raise error
306
+
307
+
308
+ def get_course_id_by_comment(comment_id: str) -> str | None:
309
+ """
310
+ Return course_id for the matching comment.
311
+ """
312
+ return MySQLBackend.get_course_id_by_comment_id(comment_id) or None
313
+
314
+
315
+ def get_user_comments(
316
+ user_id: str,
317
+ course_id: str,
318
+ flagged: Optional[bool] = False,
319
+ page: int = 1,
320
+ per_page: int = 10,
321
+ ) -> dict[str, Any]:
322
+ """
323
+ Get all comments made by a user in a specific course.
324
+
325
+ Args:
326
+ user_id: The ID of the user
327
+ course_id: The ID of the course
328
+ flagged: Filter for flagged comments
329
+ page: Page number for pagination
330
+ per_page: Number of items per page
331
+
332
+ Returns:
333
+ A dictionary containing paginated comment results
334
+ """
335
+
336
+ backend = get_backend(course_id)()
337
+
338
+ # Build query parameters
339
+ query_params: dict[str, Any] = {
340
+ "author_id": str(user_id),
341
+ "course_id": course_id,
342
+ }
343
+
344
+ if flagged:
345
+ query_params["abuse_flaggers"] = {"$ne": [], "$exists": True}
346
+
347
+ # Get total count
348
+ comment_count = backend.get_comments_count(**query_params)
349
+
350
+ # Calculate pagination
351
+ num_pages = max(1, math.ceil(comment_count / per_page))
352
+ skip = (page - 1) * per_page
353
+
354
+ # Get paginated comments
355
+ query_params["resp_skip"] = skip
356
+ query_params["resp_limit"] = per_page
357
+ query_params["sort"] = -1 # Sort by newest first
358
+
359
+ comments = backend.get_comments(**query_params)
360
+
361
+ return {
362
+ "collection": comments,
363
+ "comment_count": comment_count,
364
+ "num_pages": num_pages,
365
+ "page": page,
366
+ }