From d8f109a6993cb5af4dafb1ff99069b260adf86eb Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 2 Oct 2024 13:36:29 +0500 Subject: [PATCH] feat: migrate APIs from HTTPs to python calls (#90) * feat: add python methods to use instead of APIs - add pin, unpin, commentables count_stats and get user's data by user id python calls that'll be used by edx-platform instead of HTTPs * feat: code refactor - call python native APIs in V2 HTTPs APIs * feat: add comments APIs and enhance APIs structure - add create_child_comment, create_parent_comment, delete_comment, get_parent_comment, update_comment, python calls that'll be used by edx-platform instead of HTTPs * feat: code refactoring - code refactor - pass params instead of a single dict to python native APIs - add proper docstrings - move those functions to model_utils which are accessing models * docs: add doc for tracking native APIs responses - add responses for create_parent_comment, create_child_comment, update_comment, get_commentables_stats, get_parent_comment, get_user, pin_thread, unpin_thread native APIs * feat: remove ObjectId, use str instead * fix: quality checks * feat: migrate views * fix: mypy issues - https://github.com/edly-io/forum/actions/runs/11101805065/job/30840222003?pr=90 * fix: e2e tests * fix: linting issues * fix: active threads * fix: apis * fix: serailizers, export APIs and fix CI * refactor: revert str to objectId --------- Co-authored-by: Muhammad Faraz Maqsood Co-authored-by: Muhammad Faraz Maqsood Co-authored-by: Ali-Salman29 --- .../v2_python_native_apis_responses.rst | 382 ++++++++++++++++ forum/api/__init__.py | 84 ++++ forum/api/commentables.py | 21 + forum/api/comments.py | 296 ++++++++++++ forum/api/flags.py | 109 +++++ forum/api/pins.py | 61 +++ forum/api/search.py | 123 +++++ forum/api/subscriptions.py | 116 +++++ forum/api/threads.py | 378 ++++++++++++++++ forum/api/users.py | 416 +++++++++++++++++ forum/api/votes.py | 227 ++++++++++ forum/backends/mongodb/api.py | 184 +++++++- forum/backends/mongodb/comments.py | 8 +- forum/backends/mysql/models.py | 3 +- forum/search/comment_search.py | 40 +- forum/serializers/contents.py | 8 +- forum/serializers/users.py | 14 +- forum/serializers/votes.py | 4 +- forum/utils.py | 19 +- forum/views/commentables.py | 4 +- forum/views/comments.py | 298 +++--------- forum/views/flags.py | 95 +--- forum/views/pins.py | 22 +- forum/views/search.py | 85 +--- forum/views/subscriptions.py | 117 ++--- forum/views/threads.py | 276 ++--------- forum/views/users.py | 427 +++++------------- forum/views/votes.py | 190 +------- tests/e2e/test_search.py | 166 +++++-- tests/test_backends/test_mysql/test_api.py | 5 +- tests/test_views/test_comments.py | 17 +- tests/test_views/test_subscriptions.py | 2 +- tests/test_views/test_threads.py | 48 +- tests/test_views/test_users.py | 6 +- 34 files changed, 2918 insertions(+), 1333 deletions(-) create mode 100644 docs/references/v2_python_native_apis_responses.rst create mode 100644 forum/api/__init__.py create mode 100644 forum/api/commentables.py create mode 100644 forum/api/comments.py create mode 100644 forum/api/flags.py create mode 100644 forum/api/pins.py create mode 100644 forum/api/search.py create mode 100644 forum/api/subscriptions.py create mode 100644 forum/api/threads.py create mode 100644 forum/api/users.py create mode 100644 forum/api/votes.py diff --git a/docs/references/v2_python_native_apis_responses.rst b/docs/references/v2_python_native_apis_responses.rst new file mode 100644 index 00000000..416cf099 --- /dev/null +++ b/docs/references/v2_python_native_apis_responses.rst @@ -0,0 +1,382 @@ +=========================== +Python Native API Responses +=========================== + +This document outlines the structure of responses for various Python native APIs related to comments, threads, and user information in the context of course discussions. + +Create Parent Comment(create_parent_comment) API +================================================ +Creates a parent comment in the course discussion. + +Response Example: +----------------- + +.. code-block:: json + + { + "id": "66eaf98e6592735b5a38129f", + "body": "

parent comment

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:02:22Z", + "updated_at": "2024-09-18T16:02:22Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [], + "closed": false, + "type": "comment", + "endorsed": false, + "depth": 0, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": null, + "child_count": 0 + } + +Create Child Comment(create_child_comment) API +============================================== +Creates a child comment in response to a parent comment. + +Response Example: +----------------- + +.. code-block:: json + + { + "id": "66eafa538e98584d34d47969", + "body": "

child comment

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:05:39Z", + "updated_at": "2024-09-18T16:05:39Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [], + "closed": false, + "type": "comment", + "endorsed": false, + "depth": 1, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": "66eaf98e6592735b5a38129f", + "child_count": 0 + } + +Update Comment(update_comment) API +================================== +Updates the content of an existing comment. + +Response Example (Edit Content): +-------------------------------- + +.. code-block:: json + + { + "id": "66eaf98e6592735b5a38129f", + "body": "

parent comment editing

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:02:22Z", + "updated_at": "2024-09-18T16:07:59Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [ + { + "original_body": "

parent comment

", + "reason_code": null, + "editor_username": "faraz1", + "created_at": "2024-09-18T16:07:59Z" + } + ], + "closed": false, + "type": "comment", + "endorsed": false, + "depth": 0, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": null, + "child_count": 1, + "endorsement": null + } + +Response Example (Endorse Comment): +----------------------------------- + +.. code-block:: json + + { + "id": "66eaf98e6592735b5a38129f", + "body": "

parent comment editing

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:02:22Z", + "updated_at": "2024-09-18T16:08:51Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [ + { + "original_body": "

parent comment

", + "reason_code": null, + "editor_username": "faraz1", + "created_at": "2024-09-18T16:07:59Z" + } + ], + "closed": false, + "type": "comment", + "endorsed": true, + "depth": 0, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": null, + "child_count": 1, + "endorsement": { + "user_id": "8", + "time": "2024-09-18T16:08:51Z" + } + } + +Get Commentables Stats(get_commentables_stats) API +================================================== +Returns the statistics for the commentable objects in a course. + +Response Example: +----------------- + +.. code-block:: json + + { + "course": { + "discussion": 1, + "question": 1 + } + } + +Get Parent Comment(get_parent_comment) API +========================================== +Retrieves a parent comment in the course discussion. + +Response Example (Endorsed): +---------------------------- + +.. code-block:: json + + { + "id": "66eaf98e6592735b5a38129f", + "body": "

parent comment editing

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:02:22Z", + "updated_at": "2024-09-18T16:08:51Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [ + { + "original_body": "

parent comment

", + "reason_code": null, + "editor_username": "faraz1", + "created_at": "2024-09-18T16:07:59Z" + } + ], + "closed": false, + "type": "comment", + "endorsed": true, + "depth": 0, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": null, + "child_count": 1, + "endorsement": { + "user_id": "8", + "time": "2024-09-18T16:08:51Z" + } + } + +Response Example (Not Endorsed): +-------------------------------- + +.. code-block:: json + + { + "id": "66eaf98e6592735b5a38129f", + "body": "

parent comment editing

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": false, + "anonymous_to_peers": false, + "created_at": "2024-09-18T16:02:22Z", + "updated_at": "2024-09-18T16:21:00Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "edit_history": [ + { + "original_body": "

parent comment

", + "reason_code": null, + "editor_username": "faraz1", + "created_at": "2024-09-18T16:07:59Z" + } + ], + "closed": false, + "type": "comment", + "endorsed": false, + "depth": 0, + "thread_id": "66df3056d77f29ace2ff201d", + "parent_id": null, + "child_count": 1 + } + +Get User(get_user) API +====================== + +The `get_user` API retrieves user-specific data such as their username, followed threads, and upvoted content. + +**Response Example:** + +.. code-block:: json + + { + "id": "8", + "username": "faraz1", + "external_id": "8", + "subscribed_thread_ids": ["66df3056d77f29ace2ff201d", "66df1595a3a68c001d742c05"], + "subscribed_commentable_ids": [], + "subscribed_user_ids": [], + "follower_ids": [], + "upvoted_ids": ["66df1595a3a68c001d742c05", "66df3056d77f29ace2ff201d"], + "downvoted_ids": [], + "default_sort_key": "date" + } + +Pin Thread(pin_thread) API +========================== + +The `pin_thread` API pins a discussion thread at the top for users to easily access. + +**Response Example:** + +.. code-block:: json + + { + "id": "66df1595a3a68c001d742c05", + "body": "

test question 

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "2024-09-09T15:34:45Z", + "updated_at": "2024-09-18T16:27:05Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 1, + "up_count": 1, + "down_count": 0, + "point": 1 + }, + "abuse_flaggers": [], + "edit_history": [], + "closed": False, + "type": "thread", + "thread_type": "question", + "title": "test question", + "context": "course", + "last_activity_at": "2024-09-18T09:19:38Z", + "closed_by": None, + "tags": [], + "group_id": None, + "pinned": True + } + +Unpin Thread(unpin_thread) API +============================== + +The `unpin_thread` API unpins a previously pinned thread, removing its elevated visibility. + +**Response Example:** + +.. code-block:: json + + { + "id": "66df1595a3a68c001d742c05", + "body": "

test question 

", + "course_id": "course-v1:Arbisoft+SE002+2024_S2", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "2024-09-09T15:34:45Z", + "updated_at": "2024-09-18T16:27:49Z", + "at_position_list": [], + "user_id": "8", + "username": "faraz1", + "commentable_id": "course", + "votes": { + "count": 1, + "up_count": 1, + "down_count": 0, + "point": 1 + }, + "abuse_flaggers": [], + "edit_history": [], + "closed": False, + "type": "thread", + "thread_type": "question", + "title": "test question", + "context": "course", + "last_activity_at": "2024-09-18T09:19:38Z", + "closed_by": None, + "tags": [], + "group_id": None, + "pinned": False + } diff --git a/forum/api/__init__.py b/forum/api/__init__.py new file mode 100644 index 00000000..55a7c4a3 --- /dev/null +++ b/forum/api/__init__.py @@ -0,0 +1,84 @@ +""" +Native Python APIs. +""" + +from .commentables import get_commentables_stats +from .comments import ( + create_child_comment, + create_parent_comment, + delete_comment, + get_parent_comment, + update_comment, +) +from .flags import ( + update_comment_flag, + update_thread_flag, +) +from .pins import pin_thread, unpin_thread +from .search import search_threads +from .subscriptions import ( + create_subscription, + delete_subscription, + get_thread_subscriptions, + get_user_subscriptions, +) +from .threads import ( + create_thread, + delete_thread, + get_thread, + get_user_threads, + update_thread, +) +from .users import ( + create_user, + get_user, + get_user_active_threads, + get_user_course_stats, + mark_thread_as_read, + retire_user, + update_user, + update_username, + update_users_in_course, +) +from .votes import ( + delete_comment_vote, + delete_thread_vote, + update_comment_votes, + update_thread_votes, +) + +__all__ = [ + "create_child_comment", + "create_parent_comment", + "create_subscription", + "create_thread", + "create_user", + "delete_comment", + "delete_comment_vote", + "delete_subscription", + "delete_thread", + "delete_thread_vote", + "get_commentables_stats", + "get_parent_comment", + "get_thread", + "get_thread_subscriptions", + "get_user", + "get_user_active_threads", + "get_user_course_stats", + "get_user_subscriptions", + "get_user_threads", + "mark_thread_as_read", + "pin_thread", + "retire_user", + "search_threads", + "unpin_thread", + "update_comment", + "update_comment_flag", + "update_comment_votes", + "update_thread", + "update_thread_flag", + "update_thread_votes", + "update_user", + "update_username", + "update_users_in_course", +] diff --git a/forum/api/commentables.py b/forum/api/commentables.py new file mode 100644 index 00000000..8c142297 --- /dev/null +++ b/forum/api/commentables.py @@ -0,0 +1,21 @@ +""" +Native Python Commenttables APIs. +""" + +from forum.backends.mongodb.api import get_commentables_counts_based_on_type + + +def get_commentables_stats(course_id: str) -> dict[str, int]: + """ + Get the threads count based on thread_type and group them by commentable_id. + + Parameters: + course_id: The ID of the course. + Body: + Empty. + Response: + The threads count for the given course_id based on thread_type. + e.g. + reponse = {'course': {'discussion': 1, 'question': 1}} + """ + return get_commentables_counts_based_on_type(course_id) diff --git a/forum/api/comments.py b/forum/api/comments.py new file mode 100644 index 00000000..63344382 --- /dev/null +++ b/forum/api/comments.py @@ -0,0 +1,296 @@ +""" +Native Python Comments APIs. +""" + +import logging +from typing import Any, Optional + +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.serializers import ValidationError + +from forum.backends.mongodb.api import ( + create_comment, + delete_comment_by_id, + get_thread_by_id, + get_thread_id_by_comment_id, + get_user_by_id, + mark_as_read, + update_comment_and_get_updated_comment, + update_stats_for_course, + validate_object, +) +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread +from forum.serializers.comment import CommentSerializer +from forum.utils import ForumV2RequestError + +log = logging.getLogger(__name__) + + +def prepare_comment_api_response( + comment: dict[str, Any], + exclude_fields: Optional[list[str]] = None, +) -> dict[str, Any]: + """ + Return serialized validated data. + + Parameters: + comment: The comment details that needs to be serialized. + exclude_fields: Any fields that need to be excluded from response. + + Response: + serialized validated data of the comment. + """ + comment_data = { + **comment, + "id": str(comment.get("_id")), + "user_id": comment.get("author_id"), + "thread_id": str(comment.get("comment_thread_id")), + "username": comment.get("author_username"), + "parent_id": str(comment.get("parent_id")), + "type": str(comment.get("_type", "")).lower(), + } + if not exclude_fields: + exclude_fields = [] + exclude_fields.append("children") + serializer = CommentSerializer( + data=comment_data, + exclude_fields=exclude_fields, + ) + if not serializer.is_valid(raise_exception=True): + raise ValidationError(serializer.errors) + + return serializer.data + + +def get_parent_comment(comment_id: str) -> dict[str, Any]: + """ + Get a parent comment. + + Parameters: + comment_id: The ID of the comment. + Body: + Empty. + Response: + The details of the comment for the given comment_id. + """ + try: + comment = validate_object(Comment, comment_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for get parent comment request.") + raise ForumV2RequestError( + f"Comment does not exists with Id: {comment_id}" + ) from exc + return prepare_comment_api_response( + comment, + exclude_fields=["sk"], + ) + + +def create_child_comment( + parent_comment_id: str, + body: str, + user_id: str, + course_id: str, + anonymous: bool, + anonymous_to_peers: bool, +) -> dict[str, Any]: + """ + Create a new child comment. + + Parameters: + comment_id: The ID of the parent comment for creating it's child comment. + body: The content of the comment. + course_id: The Id of the respective course. + user_id: The requesting user id. + anonymous: anonymous flag(True or False). + anonymous_to_peers: anonymous to peers flag(True or False). + Response: + The details of the comment that is created. + """ + try: + parent_comment = validate_object(Comment, parent_comment_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for create child comment request.") + raise ForumV2RequestError( + f"Comment does not exists with Id: {parent_comment_id}" + ) from exc + + comment = create_comment( + body, + user_id, + course_id, + anonymous, + anonymous_to_peers, + 1, + get_thread_id_by_comment_id(parent_comment_id), + parent_id=parent_comment_id, + ) + if not comment: + log.error("Forumv2RequestError for create child comment request.") + raise ForumV2RequestError("comment is not created") + + user = get_user_by_id(user_id) + thread = get_thread_by_id(parent_comment["comment_thread_id"]) + if user and thread and comment: + mark_as_read(user, thread) + try: + comment_data = prepare_comment_api_response( + comment, + exclude_fields=["endorsement", "sk"], + ) + return comment_data + except ValidationError as error: + raise error + + +def update_comment( + comment_id: str, + body: Optional[str] = None, + course_id: Optional[str] = None, + user_id: Optional[str] = None, + anonymous: Optional[bool] = None, + anonymous_to_peers: Optional[bool] = None, + endorsed: Optional[bool] = None, + closed: Optional[bool] = None, + editing_user_id: Optional[str] = None, + edit_reason_code: Optional[str] = None, + endorsement_user_id: Optional[str] = None, +) -> dict[str, Any]: + """ + Update an existing child/parent comment. + + Parameters: + comment_id: The ID of the comment to be edited. + body (Optional[str]): The content of the comment. + course_id (Optional[str]): The Id of the respective course. + user_id (Optional[str]): The requesting user id. + anonymous (Optional[bool]): anonymous flag(True or False). + anonymous_to_peers (Optional[bool]): anonymous to peers flag(True or False). + endorsed (Optional[bool]): Flag indicating if the comment is endorsed by any user. + closed (Optional[bool]): Flag indicating if the comment thread is closed. + editing_user_id (Optional[str]): The ID of the user editing the comment. + edit_reason_code (Optional[str]): The reason for editing the comment, typically represented by a code. + endorsement_user_id (Optional[str]): The ID of the user endorsing the comment. + Response: + The details of the comment that is updated. + """ + try: + validate_object(Comment, comment_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for update comment request.") + raise ForumV2RequestError( + f"Comment does not exists with Id: {comment_id}" + ) from exc + + updated_comment = update_comment_and_get_updated_comment( + comment_id, + body, + course_id, + user_id, + anonymous, + anonymous_to_peers, + endorsed, + closed, + editing_user_id, + edit_reason_code, + endorsement_user_id, + ) + if not updated_comment: + log.error("Forumv2RequestError for create child comment request.") + raise ForumV2RequestError("comment is not updated") + try: + return prepare_comment_api_response( + updated_comment, + exclude_fields=( + ["endorsement", "sk"] if updated_comment.get("parent_id") else ["sk"] + ), + ) + except ValidationError as error: + raise error + + +def delete_comment(comment_id: str) -> dict[str, Any]: + """ + Delete a comment. + + Parameters: + comment_id: The ID of the comment to be deleted. + Body: + Empty. + Response: + The details of the comment that is deleted. + """ + try: + comment = validate_object(Comment, comment_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for delete comment request.") + raise ForumV2RequestError( + f"Comment does not exists with Id: {comment_id}" + ) from exc + data = prepare_comment_api_response( + comment, + exclude_fields=["endorsement", "sk"], + ) + delete_comment_by_id(comment_id) + author_id = comment["author_id"] + course_id = comment["course_id"] + parent_comment_id = data["parent_id"] + if parent_comment_id: + update_stats_for_course(author_id, course_id, replies=-1) + else: + update_stats_for_course(author_id, course_id, responses=-1) + return data + + +def create_parent_comment( + thread_id: str, + body: str, + user_id: str, + course_id: str, + anonymous: bool, + anonymous_to_peers: bool, +) -> dict[str, Any]: + """ + Create a new parent comment. + + Parameters: + thread_id: The ID of the thread for creating a comment on it. + body: The content of the comment. + course_id: The Id of the respective course. + user_id: The requesting user id. + anonymous: anonymous flag(True or False). + anonymous_to_peers: anonymous to peers flag(True or False). + Response: + The details of the comment that is created. + """ + try: + thread = validate_object(CommentThread, thread_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for create parent comment request.") + raise ForumV2RequestError( + f"Thread does not exists with Id: {thread_id}" + ) from exc + + comment = create_comment( + body, + user_id, + course_id, + anonymous, + anonymous_to_peers, + 0, + thread_id=thread_id, + ) + if not comment: + log.error("Forumv2RequestError for create parent comment request.") + raise ForumV2RequestError("comment is not created") + user = get_user_by_id(user_id) + if user and comment: + mark_as_read(user, thread) + try: + return prepare_comment_api_response( + comment, + exclude_fields=["endorsement", "sk"], + ) + except ValidationError as error: + raise error diff --git a/forum/api/flags.py b/forum/api/flags.py new file mode 100644 index 00000000..fb7029ec --- /dev/null +++ b/forum/api/flags.py @@ -0,0 +1,109 @@ +""" +This module contains the functions to update the flag status of a comment. +""" + +from typing import Any, Optional + +from forum.backends.mongodb.api import ( + flag_as_abuse, + un_flag_all_as_abuse, + un_flag_as_abuse, +) +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread +from forum.backends.mongodb.users import Users +from forum.serializers.comment import CommentSerializer +from forum.serializers.thread import ThreadSerializer +from forum.utils import ForumV2RequestError + + +def update_comment_flag( + comment_id: str, + action: str, + user_id: Optional[str] = None, + update_all: Optional[bool] = False, +) -> dict[str, Any]: + """ + Update the flag status of a comment. + + Args: + user_id (str): The ID of the user. + comment_id (str): The ID of the comment. + action (str): The action to perform ("flag" or "unflag"). + update_all (bool, optional): Whether to update all flags. Defaults to False. + """ + if not user_id: + raise ForumV2RequestError("user_id not provided in params") + user = Users().get(user_id) + comment = Comment().get(comment_id) + if not user or not comment: + raise ForumV2RequestError("User / Comment doesn't exist") + + if action == "flag": + updated_comment = flag_as_abuse(user, comment) + elif action == "unflag": + if update_all: + updated_comment = un_flag_all_as_abuse(comment) + else: + updated_comment = un_flag_as_abuse(user, comment) + else: + raise ForumV2RequestError("Invalid action") + + if updated_comment is None: + raise ForumV2RequestError("Failed to update comment") + + context = { + "id": str(updated_comment["_id"]), + **updated_comment, + "user_id": user["_id"], + "username": user["username"], + "type": "comment", + "thread_id": str(updated_comment.get("comment_thread_id", None)), + } + return CommentSerializer(context).data + + +def update_thread_flag( + thread_id: str, + action: str, + user_id: Optional[str] = None, + update_all: Optional[bool] = False, +) -> dict[str, Any]: + """ + Update the flag status of a thread. + + Args: + user_id (str): The ID of the user. + thread_id (str): The ID of the thread. + action (str): The action to perform ("flag" or "unflag"). + update_all (bool, optional): Whether to update all flags. Defaults to False. + """ + if not user_id: + raise ForumV2RequestError("user_id not provided in params") + user = Users().get(user_id) + thread = CommentThread().get(thread_id) + if not user or not thread: + raise ForumV2RequestError("User / Thread doesn't exist") + + if action == "flag": + updated_thread = flag_as_abuse(user, thread) + elif action == "unflag": + if update_all: + updated_thread = un_flag_all_as_abuse(thread) + else: + updated_thread = un_flag_as_abuse(user, thread) + else: + raise ForumV2RequestError("Invalid action") + + if updated_thread is None: + raise ForumV2RequestError("Failed to update thread") + + context = { + "id": str(updated_thread["_id"]), + **updated_thread, + "user_id": user["_id"], + "username": user["username"], + "type": "thread", + "thread_id": str(updated_thread.get("comment_thread_id", None)), + } + return ThreadSerializer(context).data diff --git a/forum/api/pins.py b/forum/api/pins.py new file mode 100644 index 00000000..ce3ab2be --- /dev/null +++ b/forum/api/pins.py @@ -0,0 +1,61 @@ +""" +Native Python Pins APIs. +""" + +import logging +from typing import Any + +from forum.backends.mongodb.api import handle_pin_unpin_thread_request +from forum.serializers.thread import ThreadSerializer +from forum.utils import ForumV2RequestError + +log = logging.getLogger(__name__) + + +def pin_unpin_thread( + user_id: str, + thread_id: str, + action: str, +) -> dict[str, Any]: + """ + Helper method to Pin or Unpin a thread. + Parameters: + user_id (str): The ID of the requested User. + thread_id (str): The ID of the thread to pin. + action: (str): It's value can be "pin" or "unpin". + Response: + A response with the updated thread data. + """ + try: + thread_data: dict[str, Any] = handle_pin_unpin_thread_request( + user_id, thread_id, action, ThreadSerializer + ) + except ValueError as e: + log.error(f"Forumv2RequestError for {action} thread request.") + raise ForumV2RequestError(str(e)) from e + + return thread_data + + +def pin_thread(user_id: str, thread_id: str) -> dict[str, Any]: + """ + Pin a thread. + Parameters: + user_id (str): The ID of the requested User. + thread_id (str): The ID of the thread to pin. + Response: + A response with the updated thread data. + """ + return pin_unpin_thread(user_id, thread_id, "pin") + + +def unpin_thread(user_id: str, thread_id: str) -> dict[str, Any]: + """ + Unpin a thread. + Parameters: + user_id (str): The ID of the requested User. + thread_id (str): The ID of the thread to pin. + Response: + A response with the updated thread data. + """ + return pin_unpin_thread(user_id, thread_id, "unpin") diff --git a/forum/api/search.py b/forum/api/search.py new file mode 100644 index 00000000..e09d887b --- /dev/null +++ b/forum/api/search.py @@ -0,0 +1,123 @@ +""" +API for search. +""" + +from typing import Any, Optional + +from forum.backends.mongodb.api import handle_threads_query +from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE +from forum.search.comment_search import ThreadSearch +from forum.serializers.thread import ThreadSerializer + + +def _get_thread_ids_from_indexes( + context: str, + group_ids: list[int], + text: str, + commentable_id: Optional[str] = None, + commentable_ids: Optional[str] = None, + course_id: Optional[str] = None, +) -> tuple[list[str], Optional[str]]: + """ + Retrieve thread IDs based on the search text and suggested corrections if necessary. + + Args: + context (str): The context in which the search is performed, e.g., "course". + group_ids (list[int]): list of group IDs to filter the search. + params (dict[str, Any]): Query parameters for the search. + text (str): The search text used to find threads. + + Returns: + tuple[Optional[list[str]], Optional[str]]: + - A list of thread IDs that match the search criteria. + - A suggested correction for the search text, or None if no correction is found. + """ + corrected_text: Optional[str] = None + thread_search = ThreadSearch() + + thread_ids = thread_search.get_thread_ids( + context, + group_ids, + text, + commentable_id=commentable_id, + commentable_ids=commentable_ids, + course_id=course_id, + ) + if not thread_ids: + corrected_text = thread_search.get_suggested_text(text, ["body", "title"]) + if corrected_text: + thread_ids = thread_search.get_thread_ids_with_corrected_text( + context, + group_ids, + corrected_text, + commentable_id=commentable_id, + commentable_ids=commentable_ids, + course_id=course_id, + ) + if not thread_ids: + corrected_text = None + + return thread_ids, corrected_text + + +def search_threads( + text: str, + sort_key: str, + context: str, + user_id: str, + course_id: str, + group_ids: list[int], + author_id: str, + thread_type: str, + flagged: bool, + unread: bool, + unanswered: bool, + unresponded: bool, + count_flagged: bool, + commentable_id: str, + commentable_ids: str, + page: int = FORUM_DEFAULT_PAGE, + per_page: int = FORUM_DEFAULT_PER_PAGE, +) -> dict[str, Any]: + """ + Search for threads based on the provided data. + """ + thread_ids, corrected_text = _get_thread_ids_from_indexes( + context, group_ids, text, commentable_id, commentable_ids, course_id + ) + + data = handle_threads_query( + thread_ids, + user_id, + course_id, + group_ids, + author_id, + thread_type, + flagged, + unread, + unanswered, + unresponded, + count_flagged, + sort_key, + page, + per_page, + context, + ) + + if collections := data.get("collection"): + thread_serializer = ThreadSerializer( + collections, + many=True, + context={ + "count_flagged": True, + "include_endorsed": True, + "include_read_state": True, + }, + ) + data["collection"] = thread_serializer.data + + if data: + data["corrected_text"] = corrected_text + data["total_results"] = len(thread_ids) + + return data diff --git a/forum/api/subscriptions.py b/forum/api/subscriptions.py new file mode 100644 index 00000000..5737609c --- /dev/null +++ b/forum/api/subscriptions.py @@ -0,0 +1,116 @@ +""" +API for subscriptions. +""" + +from typing import Any + +from django.http import QueryDict +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from forum.backends.mongodb.api import ( + find_subscribed_threads, + get_threads, + subscribe_user, + unsubscribe_user, + validate_params, +) +from forum.backends.mongodb.subscriptions import Subscriptions +from forum.backends.mongodb.threads import CommentThread +from forum.backends.mongodb.users import Users +from forum.pagination import ForumPagination +from forum.serializers.subscriptions import SubscriptionSerializer +from forum.serializers.thread import ThreadSerializer +from forum.utils import ForumV2RequestError + + +def validate_user_and_thread( + user_id: str, source_id: str +) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Validate if user and thread exist. + """ + user = Users().get(user_id) + thread = CommentThread().get(source_id) + if not (user and thread): + raise ForumV2RequestError("User / Thread doesn't exist") + return user, thread + + +def create_subscription(user_id: str, source_id: str) -> dict[str, Any]: + """ + Create a subscription for a user. + """ + _, thread = validate_user_and_thread(user_id, source_id) + subscription = subscribe_user(user_id, source_id, thread["_type"]) + serializer = SubscriptionSerializer(subscription) + return serializer.data + + +def delete_subscription(user_id: str, source_id: str) -> dict[str, Any]: + """ + Delete a subscription for a user. + """ + _, _ = validate_user_and_thread(user_id, source_id) + + subscription = Subscriptions().get_subscription( + user_id, + source_id, + ) + if not subscription: + raise ForumV2RequestError("Subscription doesn't exist") + + unsubscribe_user(user_id, source_id) + serializer = SubscriptionSerializer(subscription) + return serializer.data + + +def get_user_subscriptions( + user_id: str, course_id: str, query_params: dict[str, Any] +) -> dict[str, Any]: + """ + Get a user's subscriptions. + """ + validate_params(query_params, user_id) + thread_ids = find_subscribed_threads(user_id, course_id) + threads = get_threads(query_params, ThreadSerializer, thread_ids, user_id) + return threads + + +def get_thread_subscriptions( + thread_id: str, page: int = 1, per_page: int = 20 +) -> dict[str, Any]: + """ + Retrieve subscriptions to a specific thread. + + Args: + thread_id (str): The ID of the thread to retrieve subscriptions for. + page (int): The page number for pagination. + per_page (int): The number of items per page. + + Returns: + dict: A dictionary containing the paginated subscription data. + """ + query = {"source_id": thread_id, "source_type": "CommentThread"} + subscriptions_list = list(Subscriptions().find(query)) + + factory = APIRequestFactory() + query_params = QueryDict("", mutable=True) + query_params.update({"page": str(page), "per_page": str(per_page)}) + request = factory.get("/", query_params) + drf_request = Request(request) + + paginator = ForumPagination() + paginated_subscriptions = paginator.paginate_queryset( + subscriptions_list, drf_request + ) + + subscriptions = SubscriptionSerializer(paginated_subscriptions, many=True) + subscriptions_count = len(subscriptions.data) + + return { + "collection": subscriptions.data, + "subscriptions_count": subscriptions_count, + "page": page, + "num_pages": max(1, subscriptions_count // per_page), + } diff --git a/forum/api/threads.py b/forum/api/threads.py new file mode 100644 index 00000000..4a1fd680 --- /dev/null +++ b/forum/api/threads.py @@ -0,0 +1,378 @@ +""" +Native Python Threads APIs. +""" + +import logging +from typing import Any, Optional + +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.serializers import ValidationError + +from forum.backends.mongodb.api import ( + delete_comments_of_a_thread, + delete_subscriptions_of_a_thread, + get_threads, +) +from forum.backends.mongodb.api import mark_as_read as mark_thread_as_read +from forum.backends.mongodb.api import ( + update_stats_for_course, + validate_object, + validate_params, +) +from forum.backends.mongodb.threads import CommentThread +from forum.backends.mongodb.users import Users +from forum.serializers.thread import ThreadSerializer +from forum.utils import ForumV2RequestError, get_int_value_from_collection, str_to_bool + +log = logging.getLogger(__name__) + + +def _get_thread_data_from_request_data(data: dict[str, Any]) -> dict[str, Any]: + """convert request data to a dict excluding empty data""" + fields = [ + "title", + "body", + "course_id", + "anonymous", + "anonymous_to_peers", + "closed", + "commentable_id", + "thread_type", + "edit_reason_code", + "close_reason_code", + "endorsed", + "pinned", + ] + result = {field: data.get(field) for field in fields if data.get(field) is not None} + + # Handle special cases + if "user_id" in data: + result["author_id"] = data["user_id"] + if "editing_user_id" in data: + result["editing_user_id"] = data["editing_user_id"] + if "closing_user_id" in data: + result["closed_by_id"] = data["closing_user_id"] + + return result + + +def get_thread_data(thread: dict[str, Any]) -> dict[str, Any]: + """Prepare thread data for the api response.""" + _type = str(thread.get("_type", "")).lower() + thread_data = { + **thread, + "id": str(thread.get("_id")), + "type": "thread" if _type == "commentthread" else _type, + "user_id": thread.get("author_id"), + "username": str(thread.get("author_username")), + "comments_count": thread["comment_count"], + } + return thread_data + + +def prepare_thread_api_response( + thread: dict[str, Any], + include_context: Optional[bool] = False, + data_or_params: Optional[dict[str, Any]] = None, + include_data_from_params: Optional[bool] = False, +) -> dict[str, Any]: + """Serialize thread data for the api response.""" + thread_data = get_thread_data(thread) + + context = {} + if include_context: + context = { + "include_endorsed": True, + "include_read_state": True, + } + if data_or_params: + if user_id := data_or_params.get("user_id"): + context["user_id"] = user_id + + if include_data_from_params: + thread_data["resp_skip"] = get_int_value_from_collection( + data_or_params, "resp_skip", 0 + ) + thread_data["resp_limit"] = get_int_value_from_collection( + data_or_params, "resp_limit", 100 + ) + params = [ + "recursive", + "with_responses", + "mark_as_read", + "reverse_order", + "merge_question_type_responses", + ] + for param in params: + if value := data_or_params.get(param): + context[param] = str_to_bool(value) + if user_id and (user := Users().get(user_id)): + mark_thread_as_read(user, thread) + + serializer = ThreadSerializer( + data=thread_data, + context=context, + ) + if not serializer.is_valid(raise_exception=True): + log.error(f"validation error in thread API call: {serializer.errors}") + raise ValidationError(serializer.errors) + + return serializer.data + + +def get_thread( + thread_id: str, + params: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """ + Get the thread for the given thread_id. + + Parameters: + thread_id: The ID of the thread. + user_id: The ID of the user requesting the thread. + resp_skip: Number of responses to skip. + resp_limit: Maximum number of responses to return. + recursive: Whether to include nested responses. + with_responses: Whether to include responses. + mark_as_read: Whether to mark the thread as read. + reverse_order: Whether to reverse the order of responses. + merge_question_type_responses: Whether to merge question type responses. + Response: + The details of the thread for the given thread_id. + """ + try: + thread = validate_object(CommentThread, thread_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for get thread request.") + raise ForumV2RequestError( + f"Thread does not exist with Id: {thread_id}" + ) from exc + + try: + return prepare_thread_api_response( + thread, + True, + params, + True, + ) + except ValidationError as error: + log.error(f"Validation error in get_thread: {error}") + raise ForumV2RequestError("Failed to prepare thread API response") from error + + +def delete_thread(thread_id: str) -> dict[str, Any]: + """ + Delete the thread for the given thread_id. + + Parameters: + thread_id: The ID of the thread to be deleted. + Response: + The details of the thread that is deleted. + """ + try: + thread = validate_object(CommentThread, thread_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for delete thread request.") + raise ForumV2RequestError( + f"Thread does not exist with Id: {thread_id}" + ) from exc + + delete_comments_of_a_thread(thread_id) + thread = validate_object(CommentThread, thread_id) + + try: + serialized_data = prepare_thread_api_response(thread) + except ValidationError as error: + log.error(f"Validation error in get_thread: {error}") + raise ForumV2RequestError("Failed to prepare thread API response") from error + + result = CommentThread().delete(thread_id) + delete_subscriptions_of_a_thread(thread_id) + if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): + update_stats_for_course(thread["author_id"], thread["course_id"], threads=-1) + + return serialized_data + + +def update_thread( + thread_id: str, + title: Optional[str] = None, + body: Optional[str] = None, + course_id: Optional[str] = None, + anonymous: Optional[bool] = None, + anonymous_to_peers: Optional[bool] = None, + closed: Optional[bool] = None, + commentable_id: Optional[str] = None, + user_id: Optional[str] = None, + editing_user_id: Optional[str] = None, + pinned: Optional[bool] = None, + thread_type: Optional[str] = None, + edit_reason_code: Optional[str] = None, + close_reason_code: Optional[str] = None, + closing_user_id: Optional[str] = None, + endorsed: Optional[bool] = None, +) -> dict[str, Any]: + """ + Update the thread for the given thread_id. + + Parameters: + thread_id: The ID of the thread to be updated. + data: The data to be updated. + Response: + The details of the thread that is updated. + """ + try: + thread = validate_object(CommentThread, thread_id) + except ObjectDoesNotExist as exc: + log.error("Forumv2RequestError for update thread request.") + raise ForumV2RequestError( + f"Thread does not exist with Id: {thread_id}" + ) from exc + + data = { + "title": title, + "body": body, + "course_id": course_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "closed": closed, + "commentable_id": commentable_id, + "user_id": user_id, + "editing_user_id": editing_user_id, + "pinned": pinned, + "thread_type": thread_type, + "edit_reason_code": edit_reason_code, + "close_reason_code": close_reason_code, + "closing_user_id": closing_user_id, + "endorsed": endorsed, + } + update_thread_data: dict[str, Any] = _get_thread_data_from_request_data(data) + + if "body" in update_thread_data: + update_thread_data["original_body"] = thread.get("body") + + if update_thread_data.get("closed"): + missing_fields = {"close_reason_code", "closed_by_id"} - set( + update_thread_data.keys() + ) + if missing_fields: + raise ForumV2RequestError( + f"Missing required fields: {', '.join(missing_fields)}" + ) + CommentThread().update(thread_id, **update_thread_data) + thread = CommentThread().get(thread_id) + + try: + return prepare_thread_api_response( + thread, + True, + data, + ) + except ValidationError as error: + log.error(f"Validation error in get_thread: {error}") + raise ForumV2RequestError("Failed to prepare thread API response") from error + + +def create_thread( + title: str, + body: str, + course_id: str, + user_id: str, + anonymous: bool = False, + anonymous_to_peers: bool = False, + commentable_id: str = "course", + thread_type: str = "discussion", +) -> dict[str, Any]: + """ + Create a new thread. + + Parameters: + title: The title of the thread. + body: The body of the thread. + course_id: The ID of the course. + anonymous: Whether the thread is anonymous. + anonymous_to_peers: Whether the thread is anonymous to peers. + closed: Whether the thread is closed. + commentable_id: The ID of the commentable. + user_id: The ID of the user. + Response: + The details of the thread that is created. + """ + data = { + "title": title, + "body": body, + "course_id": course_id, + "user_id": user_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "commentable_id": commentable_id, + "thread_type": thread_type, + } + thread_data: dict[str, Any] = _get_thread_data_from_request_data(data) + + thread_id = CommentThread().insert(**thread_data) + thread = CommentThread().get(thread_id) + if not thread: + raise ForumV2RequestError(f"Failed to create thread with data: {data}") + + if not (anonymous or anonymous_to_peers): + update_stats_for_course(thread["author_id"], thread["course_id"], threads=1) + + try: + return prepare_thread_api_response( + thread, + True, + data, + ) + except ValidationError as error: + log.error(f"Validation error in get_thread: {error}") + raise ForumV2RequestError("Failed to prepare thread API response") from error + + +def get_user_threads( + course_id: Optional[str] = None, + author_id: Optional[str] = None, + thread_type: Optional[str] = None, + flagged: Optional[bool] = None, + unread: Optional[bool] = None, + unanswered: Optional[bool] = None, + unresponded: Optional[bool] = None, + count_flagged: Optional[bool] = None, + sort_key: Optional[str] = None, + page: Optional[str] = None, + per_page: Optional[str] = None, + request_id: Optional[str] = None, + commentable_ids: Optional[str] = None, + user_id: Optional[str] = None, +) -> dict[str, Any]: + """ + Get the threads for the given thread_ids. + """ + params = { + "course_id": course_id, + "author_id": author_id, + "thread_type": thread_type, + "flagged": flagged, + "unread": unread, + "unanswered": unanswered, + "unresponded": unresponded, + "count_flagged": count_flagged, + "sort_key": sort_key, + "page": int(page) if page else None, + "per_page": int(per_page) if per_page else None, + "request_id": request_id, + "commentable_ids": commentable_ids, + "user_id": user_id, + } + params = {k: v for k, v in params.items() if v is not None} + validate_params(params) + + thread_filter = { + "_type": {"$in": [CommentThread.content_type]}, + "course_id": {"$in": [course_id]}, + } + filtered_threads = CommentThread().find(thread_filter) + thread_ids = [thread["_id"] for thread in filtered_threads] + threads = get_threads(params, ThreadSerializer, thread_ids, user_id or "") + + return threads diff --git a/forum/api/users.py b/forum/api/users.py new file mode 100644 index 00000000..30831d7c --- /dev/null +++ b/forum/api/users.py @@ -0,0 +1,416 @@ +""" +Native Python Users APIs. +""" + +import logging +import math +from typing import Any, Optional + +from forum.backends.mongodb import Users +from forum.backends.mongodb.api import ( + find_or_create_user, + get_user_by_username, + handle_threads_query, + mark_as_read, + replace_username_in_all_content, + retire_all_content, + unsubscribe_all, + update_all_users_in_course, + user_to_hash, +) +from forum.backends.mongodb.contents import Contents +from forum.backends.mongodb.threads import CommentThread +from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE +from forum.serializers.thread import ThreadSerializer +from forum.serializers.users import UserSerializer +from forum.utils import ForumV2RequestError + +log = logging.getLogger(__name__) + + +def get_user( + user_id: str, + group_ids: list[int], + course_id: Optional[str] = None, + complete: Optional[bool] = False, +) -> dict[str, Any]: + """Get user data by user_id.""" + """ + Get users data by user_id. + Parameters: + user_id (str): The ID of the requested User. + params (str): attributes for user's data filteration. + Response: + A response with the users data. + """ + user = Users().get(user_id) + if not user: + log.error(f"Forumv2RequestError for retrieving user's data for id {user_id}.") + raise ForumV2RequestError(str(f"user not found with id: {user_id}")) + + params = { + "complete": complete, + "group_ids": group_ids, + "course_id": course_id, + } + hashed_user = user_to_hash(user, params) + serializer = UserSerializer(hashed_user) + return serializer.data + + +def update_user( + user_id: str, + username: Optional[str] = None, + default_sort_key: Optional[str] = None, + course_id: Optional[str] = None, + group_ids: Optional[list[int]] = None, + complete: Optional[bool] = False, +) -> dict[str, Any]: + """Update user.""" + user = Users().get(user_id) + user_by_username = get_user_by_username(username) + if user and user_by_username: + if user["external_id"] != user_by_username["external_id"]: + raise ForumV2RequestError("user does not match") + elif user_by_username: + raise ForumV2RequestError(f"user already exists with username: {username}") + else: + user_id = find_or_create_user(user_id) + Users().update(user_id, username=username, default_sort_key=default_sort_key) + updated_user = Users().get(user_id) + if not updated_user: + raise ForumV2RequestError(f"user not found with id: {user_id}") + params = { + "complete": complete, + "group_ids": group_ids, + "course_id": course_id, + } + hashed_user = user_to_hash(updated_user, params) + serializer = UserSerializer(hashed_user) + return serializer.data + + +def create_user( + user_id: str, + username: str, + default_sort_key: str = "date", + course_id: Optional[str] = None, + group_ids: Optional[list[int]] = None, + complete: bool = False, +) -> dict[str, Any]: + """Create user.""" + user_by_id = Users().get(user_id) + user_by_username = get_user_by_username(username) + + if user_by_id or user_by_username: + raise ForumV2RequestError(f"user already exists with id: {id}") + + Users().insert( + external_id=user_id, username=username, default_sort_key=default_sort_key + ) + user = Users().get(user_id) + if not user: + raise ForumV2RequestError(f"user not found with id: {user_id}") + params = { + "complete": complete, + "group_ids": group_ids, + "course_id": course_id, + } + hashed_user = user_to_hash(user, params) + serializer = UserSerializer(hashed_user) + return serializer.data + + +def update_username(user_id: str, new_username: str) -> dict[str, str]: + """Update username.""" + user = Users().get(user_id) + if not user: + raise ForumV2RequestError(str(f"user not found with id: {user_id}")) + Users().update(user_id, username=new_username) + replace_username_in_all_content(user_id, new_username) + return {"message": "Username updated successfully"} + + +def retire_user(user_id: str, retired_username: str) -> dict[str, str]: + """Retire user.""" + user = Users().get(user_id) + if not user: + raise ForumV2RequestError(f"user not found with id: {user_id}") + Users().update( + user_id, + email="", + username=retired_username, + read_states=[], + ) + unsubscribe_all(user_id) + retire_all_content(user_id, retired_username) + + return {"message": "User retired successfully"} + + +def mark_thread_as_read( + user_id: str, + source_id: str, + complete: bool = False, + course_id: Optional[str] = None, + group_ids: Optional[list[int]] = None, +) -> dict[str, Any]: + """Mark thread as read.""" + user = Users().get(user_id) + if not user: + raise ForumV2RequestError(str(f"user not found with id: {user_id}")) + + thread = CommentThread().get(source_id) + if not thread: + raise ForumV2RequestError(str(f"source not found with id: {source_id}")) + + mark_as_read(user, thread) + + user = Users().get(user_id) + if not user: + raise ForumV2RequestError(str(f"user not found with id: {user_id}")) + + params = { + "complete": complete, + "group_ids": group_ids, + "course_id": course_id, + } + + hashed_user = user_to_hash(user, params) + serializer = UserSerializer(hashed_user) + return serializer.data + + +def get_user_active_threads( + user_id: str, + course_id: str, + author_id: Optional[str] = None, + thread_type: Optional[str] = None, + flagged: Optional[bool] = False, + unread: Optional[bool] = False, + unanswered: Optional[bool] = False, + unresponded: Optional[bool] = False, + count_flagged: Optional[bool] = False, + sort_key: Optional[str] = "user_activity", + page: Optional[int] = FORUM_DEFAULT_PAGE, + per_page: Optional[int] = FORUM_DEFAULT_PER_PAGE, + group_id: Optional[str] = None, +) -> dict[str, Any]: + """Get user active threads.""" + raw_query = bool(sort_key == "user_activity") + if not course_id: + return {} + active_contents = list( + Contents().get_list( + author_id=user_id, + anonymous=False, + anonymous_to_peers=False, + course_id=course_id, + ) + ) + + if flagged: + active_contents = [ + content + for content in active_contents + if content["abuse_flaggers"] and len(content["abuse_flaggers"]) > 0 + ] + active_contents = sorted( + active_contents, key=lambda x: x["updated_at"], reverse=True + ) + active_thread_ids = list( + set( + ( + content["comment_thread_id"] + if content["_type"] == "Comment" + else content["_id"] + ) + for content in active_contents + ) + ) + + params: dict[str, Any] = { + "comment_thread_ids": active_thread_ids, + "user_id": user_id, + "course_id": course_id, + "group_ids": [int(group_id)] if group_id else [], + "author_id": author_id, + "thread_type": thread_type, + "filter_flagged": flagged, + "filter_unread": unread, + "filter_unanswered": unanswered, + "filter_unresponded": unresponded, + "count_flagged": count_flagged, + "sort_key": sort_key, + "page": page, + "per_page": per_page, + "context": "course", + "raw_query": raw_query, + } + data = handle_threads_query(**params) + + if collections := data.get("collection"): + thread_serializer = ThreadSerializer( + collections, + many=True, + context={ + "count_flagged": count_flagged, + "include_endorsed": True, + "include_read_state": True, + }, + ) + data["collection"] = thread_serializer.data + else: + collection = data.get("result", []) + for thread in collection: + thread["_id"] = str(thread.pop("_id")) + thread["type"] = str(thread.get("_type", "")).lower() + data["collection"] = ThreadSerializer(collection, many=True).data + + return data + + +def _create_pipeline( + course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] +) -> list[dict[str, Any]]: + """Get pipeline for course stats api.""" + pipeline: list[dict[str, Any]] = [ + {"$match": {"course_stats.course_id": course_id}}, + {"$project": {"username": 1, "course_stats": 1}}, + {"$unwind": "$course_stats"}, + {"$match": {"course_stats.course_id": course_id}}, + {"$sort": sort_criterion}, + { + "$facet": { + "pagination": [{"$count": "total_count"}], + "data": [ + {"$skip": (page - 1) * per_page}, + {"$limit": per_page}, + ], + } + }, + ] + return pipeline + + +def _get_sort_criterion(sort_by: str) -> dict[str, Any]: + """Get sort criterion based on sort_by parameter.""" + if sort_by == "flagged": + return { + "course_stats.active_flags": -1, + "course_stats.inactive_flags": -1, + "username": -1, + } + elif sort_by == "recency": + return { + "course_stats.last_activity_at": -1, + "username": -1, + } + else: + return { + "course_stats.threads": -1, + "course_stats.responses": -1, + "course_stats.replies": -1, + "username": -1, + } + + +def _get_paginated_stats( + course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] +) -> dict[str, Any]: + """Get paginated stats for a course.""" + pipeline = _create_pipeline(course_id, page, per_page, sort_criterion) + return list(Users().aggregate(pipeline))[0] + + +def _get_user_data( + user_stats: dict[str, Any], exclude_from_stats: list[str] +) -> dict[str, Any]: + """Get user data from user stats.""" + user_data = {"username": user_stats["username"]} + for k, v in user_stats["course_stats"].items(): + if k not in exclude_from_stats: + user_data[k] = v + return user_data + + +def _get_stats_for_usernames( + course_id: str, usernames: list[str] +) -> list[dict[str, Any]]: + """Get stats for specific usernames.""" + users = Users().get_list() + stats_query = [] + for user in users: + if user["username"] not in usernames: + continue + course_stats = user["course_stats"] + if course_stats: + for course_stat in course_stats: + if course_stat["course_id"] == course_id: + stats_query.append( + {"username": user["username"], "course_stats": course_stat} + ) + break + return sorted(stats_query, key=lambda u: usernames.index(u["username"])) + + +def get_user_course_stats( + course_id: str, + usernames: Optional[str] = None, + page: int = FORUM_DEFAULT_PAGE, + per_page: int = FORUM_DEFAULT_PER_PAGE, + sort_key: str = "", + with_timestamps: bool = False, +) -> dict[str, Any]: + """Get user course stats.""" + + sort_criterion = _get_sort_criterion(sort_key) + exclude_from_stats = ["_id", "course_id"] + if not with_timestamps: + exclude_from_stats.append("last_activity_at") + + usernames_list = usernames.split(",") if usernames else None + data = [] + + if not usernames_list: + paginated_stats = _get_paginated_stats( + course_id, page, per_page, sort_criterion + ) + num_pages = 0 + page = 0 + total_count = 0 + if paginated_stats.get("pagination"): + total_count = paginated_stats["pagination"][0]["total_count"] + num_pages = max(1, math.ceil(total_count / per_page)) + data = [ + _get_user_data(user_stats, exclude_from_stats) + for user_stats in paginated_stats["data"] + ] + else: + stats_query = _get_stats_for_usernames(course_id, usernames_list) + total_count = len(stats_query) + num_pages = 1 + data = [ + { + "username": user_stats["username"], + **{ + k: v + for k, v in user_stats["course_stats"].items() + if k not in exclude_from_stats + }, + } + for user_stats in stats_query + ] + + return { + "user_stats": data, + "num_pages": num_pages, + "page": page, + "count": total_count, + } + + +def update_users_in_course(course_id: str) -> dict[str, int]: + """Update all user stats in a course.""" + updated_users = update_all_users_in_course(course_id) + return {"user_count": len(updated_users)} diff --git a/forum/api/votes.py b/forum/api/votes.py new file mode 100644 index 00000000..085e6a44 --- /dev/null +++ b/forum/api/votes.py @@ -0,0 +1,227 @@ +""" +API for votes. +""" + +from typing import Any + +from forum.backends.mongodb.api import downvote_content, remove_vote, upvote_content +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread +from forum.backends.mongodb.users import Users +from forum.serializers.comment import CommentSerializer +from forum.serializers.thread import ThreadSerializer +from forum.serializers.votes import VotesInputSerializer +from forum.utils import ForumV2RequestError + + +def _get_thread_and_user( + thread_id: str, user_id: str +) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Fetches the thread and user based on provided IDs. + + Args: + thread_id (str): The ID of the thread. + user_id (str): The ID of the user. + + Returns: + tuple: The thread and user objects. + + Raises: + ValueError: If the thread or user is not found. + """ + thread = CommentThread().get(_id=thread_id) + if not thread: + raise ValueError("Thread not found") + + user = Users().get(_id=user_id) + if not user: + raise ValueError("User not found") + + return thread, user + + +def _prepare_thread_response( + thread: dict[str, Any], user: dict[str, Any] +) -> dict[str, Any]: + """ + Prepares the serialized response data after voting. + + Args: + thread (dict): The thread data. + user (dict): The user data. + + Returns: + dict: The serialized response data. + + Raises: + ValueError: If serialization fails. + """ + context = { + "id": str(thread["_id"]), + **thread, + "user_id": user["_id"], + "username": user["username"], + "type": "thread", + } + serializer = ThreadSerializer(data=context) + if not serializer.is_valid(): + raise ValueError(serializer.errors) + return serializer.data + + +def update_thread_votes(thread_id: str, user_id: str, value: str) -> dict[str, Any]: + """ + Updates the votes for a thread. + + Args: + thread_id (str): The ID of the thread. + user_id (str): The ID of the user. + value (str): The vote value ("up" or "down"). + """ + data = {"user_id": user_id, "value": value} + vote_serializer = VotesInputSerializer(data=data) + + if not vote_serializer.is_valid(): + raise ForumV2RequestError(vote_serializer.errors) + + try: + thread, user = _get_thread_and_user(thread_id, user_id) + except ValueError as error: + raise ForumV2RequestError(str(error)) from error + + if vote_serializer.data["value"] == "up": + is_updated = upvote_content(thread, user) + else: + is_updated = downvote_content(thread, user) + + if is_updated: + thread = CommentThread().get(_id=thread_id) or {} + + return _prepare_thread_response(thread, user) + + +def delete_thread_vote(thread_id: str, user_id: str) -> dict[str, Any]: + """ + Deletes the vote for a thread. + + Args: + thread_id (str): The ID of the thread. + user_id (str): The ID of the user. + """ + try: + thread, user = _get_thread_and_user(thread_id, user_id) + except ValueError as error: + raise ForumV2RequestError(str(error)) from error + + if remove_vote(thread, user): + thread = CommentThread().get(_id=thread_id) or {} + + return _prepare_thread_response(thread, user) + + +def _get_comment_and_user( + comment_id: str, user_id: str +) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Fetches the comment and user based on provided IDs. + + Args: + comment_id (str): The ID of the comment. + user_id (str): The ID of the user. + + Returns: + tuple: The comment and user objects. + + Raises: + ValueError: If the comment or user is not found. + """ + comment = Comment().get(_id=comment_id) + if not comment: + raise ValueError("Comment not found") + + user = Users().get(_id=user_id) + if not user: + raise ValueError("User not found") + + return comment, user + + +def _prepare_comment_response( + comment: dict[str, Any], user: dict[str, Any] +) -> dict[str, Any]: + """ + Prepares the serialized response data after voting. + + Args: + comment (dict): The comment data. + user (dict): The user data. + + Returns: + dict: The serialized response data. + + Raises: + ValueError: If serialization fails. + """ + context = { + "id": str(comment["_id"]), + **comment, + "user_id": user["_id"], + "username": user["username"], + "type": "comment", + "thread_id": str(comment.get("comment_thread_id", None)), + } + serializer = CommentSerializer(data=context) + if not serializer.is_valid(): + raise ValueError(serializer.errors) + return serializer.data + + +def update_comment_votes(comment_id: str, user_id: str, value: str) -> dict[str, Any]: + """ + Updates the votes for a comment. + + Args: + comment_id (str): The ID of the comment. + user_id (str): The ID of the user. + value (str): The vote value ("up" or "down"). + """ + data = {"user_id": user_id, "value": value} + vote_serializer = VotesInputSerializer(data=data) + + if not vote_serializer.is_valid(): + raise ForumV2RequestError(vote_serializer.errors) + + try: + comment, user = _get_comment_and_user(comment_id, user_id) + except ValueError as error: + raise ForumV2RequestError(str(error)) from error + + if vote_serializer.data["value"] == "up": + is_updated = upvote_content(comment, user) + else: + is_updated = downvote_content(comment, user) + + if is_updated: + comment = Comment().get(_id=comment_id) or {} + + return _prepare_comment_response(comment, user) + + +def delete_comment_vote(comment_id: str, user_id: str) -> dict[str, Any]: + """ + Deletes the vote for a comment. + + Args: + comment_id (str): The ID of the comment. + user_id (str): The ID of the user. + """ + try: + comment, user = _get_comment_and_user(comment_id, user_id) + except ValueError as error: + raise ForumV2RequestError(str(error)) from error + + if remove_vote(comment, user): + comment = Comment().get(_id=comment_id) or {} + + return _prepare_comment_response(comment, user) diff --git a/forum/backends/mongodb/api.py b/forum/backends/mongodb/api.py index f6b914cd..90d6b7c2 100644 --- a/forum/backends/mongodb/api.py +++ b/forum/backends/mongodb/api.py @@ -6,8 +6,6 @@ from bson import ObjectId from django.core.exceptions import ObjectDoesNotExist -from rest_framework import status -from rest_framework.response import Response from forum.backends.mongodb import ( Comment, @@ -17,7 +15,12 @@ Users, ) from forum.constants import RETIRED_BODY, RETIRED_TITLE -from forum.utils import get_group_ids_from_params, get_sort_criteria, make_aware +from forum.utils import ( + ForumV2RequestError, + get_group_ids_from_params, + get_sort_criteria, + make_aware, +) def update_stats_for_course(user_id: str, course_id: str, **kwargs: Any) -> None: @@ -414,6 +417,43 @@ def get_read_states( return read_states +def get_filtered_thread_ids( + thread_ids: list[str], context: str, group_ids: list[str] +) -> set[str]: + """ + Filters thread IDs based on context and group ID criteria. + + Args: + thread_ids (list[str]): List of thread IDs to filter. + context (str): The context to filter by. + group_ids (list[str]): List of group IDs for group-based filtering. + + Returns: + set: A set of filtered thread IDs based on the context and group ID criteria. + """ + context_query = { + "_id": {"$in": [ObjectId(tid) for tid in thread_ids]}, + "context": context, + } + context_threads = CommentThread().find(context_query) + context_thread_ids = {str(thread["_id"]) for thread in context_threads} + + if not group_ids: + return context_thread_ids + + group_query = { + "_id": {"$in": [ObjectId(tid) for tid in thread_ids]}, + "$or": [ + {"group_id": {"$in": group_ids}}, + {"group_id": {"$exists": False}}, + ], + } + group_threads = CommentThread().find(group_query) + group_thread_ids = {str(thread["_id"]) for thread in group_threads} + + return context_thread_ids.union(group_thread_ids) + + def get_endorsed(thread_ids: list[str]) -> dict[str, bool]: """ Retrieves endorsed status for each thread in the provided list of thread IDs. @@ -794,9 +834,7 @@ def delete_subscriptions_of_a_thread(thread_id: str) -> None: ) -def validate_params( - params: dict[str, Any], user_id: Optional[str] = None -) -> Response | None: +def validate_params(params: dict[str, Any], user_id: Optional[str] = None) -> None: """ Validate the request parameters. @@ -828,33 +866,22 @@ def validate_params( for key in params: if key not in valid_params: - return Response( - {"error": f"Invalid parameter: {key}"}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise ForumV2RequestError(f"Invalid parameter: {key}") if "course_id" not in params: - return Response( - {"error": "Missing required parameter: course_id"}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise ForumV2RequestError("Missing required parameter: course_id") if user_id: user = Users().get(user_id) if not user: - return Response( - {"error": "User doesn't exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return None + raise ForumV2RequestError("User doesn't exist") def get_threads( params: dict[str, Any], - user_id: str, serializer: Any, thread_ids: list[str], + user_id: str = "", ) -> dict[str, Any]: """get subscribed or all threads of a specific course for a specific user.""" count_flagged = bool(params.get("count_flagged", False)) @@ -1268,3 +1295,118 @@ def find_or_create_user(user_id: str) -> str: return user["external_id"] user_id = Users().insert(user_id) return user_id + + +def create_comment( + body: str, + user_id: str, + course_id: str, + anonymous: bool, + anonymous_to_peers: bool, + depth: int, + thread_id: str, + parent_id: Optional[str] = None, +) -> Any: + """ + handle comment creation and returns a comment. + + Parameters: + body: The content of the comment. + course_id: The Id of the respective course. + user_id: The requesting user id. + anonymous: anonymous flag(True or False). + anonymous_to_peers: anonymous to peers flag(True or False). + depth: It's value is 0 for parent comment and 1 for child comment. + thread_id (Optional): Id of the Thread where this comment will belong. + parent_id (Optional): Id of the parent comment. It will be given + if creating a child comment. + Response: + The details of the comment that is created. + """ + new_comment_id = Comment().insert( + body=body, + author_id=user_id, + course_id=course_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + depth=depth, + comment_thread_id=thread_id, + parent_id=parent_id, + ) + if parent_id: + update_stats_for_course(user_id, course_id, replies=1) + else: + update_stats_for_course(user_id, course_id, responses=1) + return Comment().get(new_comment_id) + + +def get_user_by_id(user_id: str) -> dict[str, Any] | None: + """Get user by it's id.""" + return Users().get(user_id) + + +def get_thread_by_id(comment_thread_id: str) -> dict[str, Any] | None: + """Get thread by it's id.""" + return CommentThread().get(comment_thread_id) + + +def update_comment_and_get_updated_comment( + comment_id: str, + body: Optional[str] = None, + course_id: Optional[str] = None, + user_id: Optional[str] = None, + anonymous: Optional[bool] = False, + anonymous_to_peers: Optional[bool] = False, + endorsed: Optional[bool] = False, + closed: Optional[bool] = False, + editing_user_id: Optional[str] = None, + edit_reason_code: Optional[str] = None, + endorsement_user_id: Optional[str] = None, +) -> dict[str, Any] | None: + """ + Update an existing child/parent comment. + + Parameters: + comment_id: The ID of the comment to be edited. + body (Optional[str]): The content of the comment. + course_id (Optional[str]): The Id of the respective course. + user_id (Optional[str]): The requesting user id. + anonymous (Optional[bool]): anonymous flag(True or False). + anonymous_to_peers (Optional[bool]): anonymous to peers flag(True or False). + endorsed (Optional[bool]): Flag indicating if the comment is endorsed by any user. + closed (Optional[bool]): Flag indicating if the comment thread is closed. + editing_user_id (Optional[str]): The ID of the user editing the comment. + edit_reason_code (Optional[str]): The reason for editing the comment, typically represented by a code. + endorsement_user_id (Optional[str]): The ID of the user endorsing the comment. + Response: + The details of the comment that is updated. + """ + Comment().update( + comment_id, + body=body, + course_id=course_id, + author_id=user_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + endorsed=endorsed, + closed=closed, + editing_user_id=editing_user_id, + edit_reason_code=edit_reason_code, + endorsement_user_id=endorsement_user_id, + ) + return Comment().get(comment_id) + + +def delete_comment_by_id(comment_id: str) -> None: + """Delete a comment by it's Id.""" + Comment().delete(comment_id) + + +def get_thread_id_by_comment_id(parent_comment_id: str) -> str: + """ + The thread Id from the parent comment. + """ + parent_comment = Comment().get(parent_comment_id) + if parent_comment: + return parent_comment["comment_thread_id"] + raise ValueError("Comment doesn't have the thread.") diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index ae564ee6..a50563c2 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -159,8 +159,6 @@ def update( child_count: Optional[int] = None, depth: Optional[int] = None, closed: Optional[bool] = None, - edit_history: Optional[list[dict[str, Any]]] = None, - original_body: Optional[str] = None, editing_user_id: Optional[str] = None, edit_reason_code: Optional[str] = None, endorsement_user_id: Optional[str] = None, @@ -221,7 +219,11 @@ def update( update_data["endorsement"] = None if editing_user_id: - edit_history = [] if edit_history is None else edit_history + edit_history = [] + original_body = "" + if comment := Comment().get(comment_id): + edit_history = comment.get("edit_history", []) + original_body = comment.get("body", "") edit_history.append( { "author_id": editing_user_id, diff --git a/forum/backends/mysql/models.py b/forum/backends/mysql/models.py index 53032215..92fd5901 100644 --- a/forum/backends/mysql/models.py +++ b/forum/backends/mysql/models.py @@ -1,12 +1,13 @@ """MySQL models for forum v2.""" from __future__ import annotations + from datetime import datetime from typing import Any, Optional from django.contrib.auth.models import User # pylint: disable=E5142 -from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import QuerySet from django.utils import timezone diff --git a/forum/search/comment_search.py b/forum/search/comment_search.py index aad39307..1d9ff752 100644 --- a/forum/search/comment_search.py +++ b/forum/search/comment_search.py @@ -91,21 +91,23 @@ class ThreadSearch(CommentSearch): """ def build_must_clause( - self, params: dict[str, str], search_text: str + self, + search_text: str, + commentable_id: Optional[str] = None, + commentable_ids: Optional[str] = None, + course_id: Optional[str] = None, ) -> list[dict[str, Any]]: """ Build the 'must' clause for thread-specific Elasticsearch queries based on input parameters. """ must: list[dict[str, Any]] = [] - if params.get("commentable_id"): - must.append({"term": {"commentable_id": params["commentable_id"]}}) - if params.get("commentable_ids"): - must.append( - {"terms": {"commentable_id": params["commentable_ids"].split(",")}} - ) - if params.get("course_id"): - must.append({"term": {"course_id": params["course_id"]}}) + if commentable_id: + must.append({"term": {"commentable_id": commentable_id}}) + if commentable_ids: + must.append({"terms": {"commentable_id": commentable_ids.split(",")}}) + if course_id: + must.append({"term": {"course_id": course_id}}) must.append( { @@ -158,14 +160,18 @@ def get_thread_ids( self, context: str, group_ids: list[int], - params: dict[str, str], search_text: str, sort_criteria: Optional[list[dict[str, str]]] = None, + commentable_id: Optional[str] = None, + commentable_ids: Optional[str] = None, + course_id: Optional[str] = None, ) -> list[str]: """ Retrieve thread IDs based on search criteria. """ - must_clause: list[dict[str, Any]] = self.build_must_clause(params, search_text) + must_clause: list[dict[str, Any]] = self.build_must_clause( + search_text, commentable_id, commentable_ids, course_id + ) filter_clause: list[dict[str, Any]] = self.build_filter_clause( context, group_ids ) @@ -192,9 +198,11 @@ def get_thread_ids_with_corrected_text( self, context: str, group_ids: list[int], - params: dict[str, str], search_text: str, sort_criteria: Optional[list[dict[str, str]]] = None, + commentable_id: Optional[str] = None, + commentable_ids: Optional[str] = None, + course_id: Optional[str] = None, ) -> list[str]: """ The function is just used of mimicing the behaviour of the test cases. @@ -202,5 +210,11 @@ def get_thread_ids_with_corrected_text( updating the returned values. """ return self.get_thread_ids( - context, group_ids, params, search_text, sort_criteria + context, + group_ids, + search_text, + sort_criteria, + commentable_id, + commentable_ids, + course_id, ) diff --git a/forum/serializers/contents.py b/forum/serializers/contents.py index 6f2777a3..bdaafd7a 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -64,16 +64,14 @@ class ContentSerializer(serializers.Serializer[dict[str, Any]]): anonymous_to_peers = serializers.BooleanField(default=False) created_at = CustomDateTimeField(allow_null=True) updated_at = CustomDateTimeField(allow_null=True) - at_position_list = serializers.ListField(allow_null=True) + at_position_list = serializers.ListField(default=[]) user_id = serializers.CharField(source="author_id") username = serializers.CharField(source="author_username") commentable_id = serializers.CharField(default="course") votes = VoteSummarySerializer() - abuse_flaggers = serializers.ListField( - child=serializers.CharField(), allow_null=True - ) + abuse_flaggers = serializers.ListField(child=serializers.CharField(), default=[]) historical_abuse_flaggers = serializers.ListField( - child=serializers.CharField(), allow_null=True + child=serializers.CharField(), default=[] ) edit_history = EditHistorySerializer(default=[], many=True) closed = serializers.BooleanField(default=False) diff --git a/forum/serializers/users.py b/forum/serializers/users.py index 865e5c3a..5ba4a6b6 100644 --- a/forum/serializers/users.py +++ b/forum/serializers/users.py @@ -12,19 +12,17 @@ class UserSerializer(serializers.Serializer[Any]): username = serializers.CharField() external_id = serializers.CharField() subscribed_thread_ids = serializers.ListField( - child=serializers.CharField(), allow_null=True + child=serializers.CharField(), default=[] ) subscribed_commentable_ids = serializers.ListField( - child=serializers.CharField(), allow_null=True + child=serializers.CharField(), default=[] ) subscribed_user_ids = serializers.ListField( - child=serializers.CharField(), allow_null=True - ) - follower_ids = serializers.ListField(child=serializers.CharField(), allow_null=True) - upvoted_ids = serializers.ListField(child=serializers.CharField(), allow_null=True) - downvoted_ids = serializers.ListField( - child=serializers.CharField(), allow_null=True + child=serializers.CharField(), default=[] ) + follower_ids = serializers.ListField(child=serializers.CharField(), default=[]) + upvoted_ids = serializers.ListField(child=serializers.CharField(), default=[]) + downvoted_ids = serializers.ListField(child=serializers.CharField(), default=[]) default_sort_key = serializers.CharField(allow_null=True) def create(self, validated_data: dict[str, Any]) -> Any: diff --git a/forum/serializers/votes.py b/forum/serializers/votes.py index 166466d4..a996db92 100644 --- a/forum/serializers/votes.py +++ b/forum/serializers/votes.py @@ -24,8 +24,8 @@ class VotesSerializer(serializers.Serializer[dict[str, Any]]): point (int): The point value of the content. """ - up = serializers.ListField(child=serializers.CharField()) - down = serializers.ListField(child=serializers.CharField()) + up = serializers.ListField(child=serializers.CharField(), default=[]) + down = serializers.ListField(child=serializers.CharField(), default=[]) up_count = serializers.IntegerField() down_count = serializers.IntegerField() count = serializers.IntegerField() diff --git a/forum/utils.py b/forum/utils.py index 8caef623..0b613b74 100644 --- a/forum/utils.py +++ b/forum/utils.py @@ -1,7 +1,7 @@ """Forum Utils.""" -from datetime import datetime, timezone import logging +from datetime import datetime, timezone from typing import Any, Sequence import requests @@ -173,11 +173,14 @@ def get_group_ids_from_params(params: dict[str, Any]) -> list[int]: """ if "group_id" in params and "group_ids" in params: raise ValueError("Cannot specify both group_id and group_ids") - group_ids = [] - if "group_id" in params: - group_ids.append(int(params["group_id"])) - elif "group_ids" in params: - group_ids.extend([int(x) for x in params["group_ids"].split(",")]) + group_ids: str | list[str] = [] + if group_id := params.get("group_id"): + return [int(group_id)] + elif group_ids := params.get("group_ids", []): + if isinstance(group_ids, str): + return [int(x) for x in group_ids.split(",")] + elif isinstance(group_ids, list): + return [int(x) for x in group_ids] return group_ids @@ -213,3 +216,7 @@ def get_sort_criteria(sort_key: str) -> Sequence[tuple[str, int]]: return sort_criteria else: return [] + + +class ForumV2RequestError(Exception): + pass diff --git a/forum/views/commentables.py b/forum/views/commentables.py index 89d75425..058114ea 100644 --- a/forum/views/commentables.py +++ b/forum/views/commentables.py @@ -6,7 +6,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from forum.backends.mongodb.api import get_commentables_counts_based_on_type +from forum.api import get_commentables_stats class CommentablesCountAPIView(APIView): @@ -28,7 +28,7 @@ def get(self, request: Request, course_id: str) -> Response: Response: The threads count for the given course_id based on thread_type. """ - commentable_counts = get_commentables_counts_based_on_type(course_id) + commentable_counts = get_commentables_stats(course_id) return Response( commentable_counts, status=status.HTTP_200_OK, diff --git a/forum/views/comments.py b/forum/views/comments.py index 36e641b5..ed90507c 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -1,8 +1,5 @@ """Forum Comments API Views.""" -from typing import Any, Optional - -from django.core.exceptions import ObjectDoesNotExist from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -10,101 +7,14 @@ from rest_framework.serializers import ValidationError from rest_framework.views import APIView -from forum.backends.mongodb import Comment, CommentThread, Users -from forum.backends.mongodb.api import ( - mark_as_read, - validate_object, - update_stats_for_course, +from forum.api import ( + create_parent_comment, + create_child_comment, + delete_comment, + get_parent_comment, + update_comment, ) -from forum.serializers.comment import CommentSerializer -from forum.utils import str_to_bool - - -def validate_comments_request_data(data: dict[str, Any]) -> None: - """ - Validates the request data if it exists or not. - - Parameters: - data: request data to validate. - Response: - raise exception if some data does not exists. - """ - fields_to_validate = ["body", "course_id", "user_id"] - for field in fields_to_validate: - if field not in data or not data[field]: - raise ValueError(f"{field} is missing.") - - -def get_thread_id(parent_comment_id: str) -> str: - """ - The thread Id from the parent comment. - """ - parent_comment = Comment().get(parent_comment_id) - if parent_comment: - return parent_comment["comment_thread_id"] - raise ValueError("Comment doesn't have the thread.") - - -def create_comment( - data: dict[str, Any], - depth: int, - thread_id: str, - parent_id: Optional[str] = None, -) -> Any: - """handle comment creation and returns a comment""" - author_id = data["user_id"] - course_id = data["course_id"] - new_comment_id = Comment().insert( - body=data["body"], - course_id=course_id, - anonymous=str_to_bool(data.get("anonymous", "False")), - anonymous_to_peers=str_to_bool(data.get("anonymous_to_peers", "False")), - author_id=author_id, - comment_thread_id=thread_id, - parent_id=parent_id, - depth=depth, - ) - if parent_id: - update_stats_for_course(author_id, course_id, replies=1) - else: - update_stats_for_course(author_id, course_id, responses=1) - return Comment().get(new_comment_id) - - -def prepare_comment_api_response( - comment: dict[str, Any], - exclude_fields: Optional[list[str]] = None, -) -> dict[str, Any]: - """ - Return serialized validated data. - - Parameters: - comment: The comment details that needs to be serialized. - exclude_fields: Any fields that need to be excluded from response. - - Response: - serialized validated data of the comment. - """ - comment_data = { - **comment, - "id": str(comment.get("_id")), - "user_id": comment.get("author_id"), - "thread_id": str(comment.get("comment_thread_id")), - "username": comment.get("author_username"), - "parent_id": str(comment.get("parent_id")), - "type": str(comment.get("_type", "")).lower(), - } - if not exclude_fields: - exclude_fields = [] - exclude_fields.append("children") - serializer = CommentSerializer( - data=comment_data, - exclude_fields=exclude_fields, - ) - if not serializer.is_valid(raise_exception=True): - raise ValidationError(serializer.errors) - - return serializer.data +from forum.utils import ForumV2RequestError, str_to_bool class CommentsAPIView(APIView): @@ -129,16 +39,12 @@ def get(self, request: Request, comment_id: str) -> Response: The details of the comment for the given comment_id. """ try: - comment = validate_object(Comment, comment_id) - except ObjectDoesNotExist: + data = get_parent_comment(comment_id) + except ForumV2RequestError: return Response( - {"error": "Comment does not exist"}, + {"error": f"Comment does not exist with Id: {comment_id}"}, status=status.HTTP_400_BAD_REQUEST, ) - data = prepare_comment_api_response( - comment, - exclude_fields=["sk"], - ) return Response(data, status=status.HTTP_200_OK) def post(self, request: Request, comment_id: str) -> Response: @@ -160,44 +66,26 @@ def post(self, request: Request, comment_id: str) -> Response: The details of the comment that is created. """ try: - parent_comment = validate_object(Comment, comment_id) - except ObjectDoesNotExist: - return Response( - {"error": "Comment does not exist"}, - status=status.HTTP_400_BAD_REQUEST, + request_data = request.data + comment = create_child_comment( + comment_id, + request_data["body"], + request_data["user_id"], + request_data["course_id"], + str_to_bool(request_data.get("anonymous", False)), + str_to_bool(request_data.get("anonymous_to_peers", False)), ) - data = request.data - try: - validate_comments_request_data(data) - thread_id = get_thread_id(comment_id) - except ValueError as error: + except ForumV2RequestError: return Response( - {"error": str(error)}, + {"error": f"Comment does not exist with Id: {comment_id}"}, status=status.HTTP_400_BAD_REQUEST, ) - - comment = create_comment(data, 1, thread_id, parent_id=comment_id) - user = Users().get(data["user_id"]) - thread = CommentThread().get(parent_comment["comment_thread_id"]) - if user and thread and comment: - mark_as_read(user, thread) - try: - if comment: - response_data = prepare_comment_api_response( - comment, - exclude_fields=["endorsement", "sk"], - ) - return Response(response_data, status=status.HTTP_200_OK) - except ValidationError as error: + except ValidationError as e: return Response( - error.detail, + {"error": e.detail}, status=status.HTTP_400_BAD_REQUEST, ) - - return Response( - {"error": "Comment is not created"}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(comment, status=status.HTTP_200_OK) def put(self, request: Request, comment_id: str) -> Response: """ @@ -212,41 +100,39 @@ def put(self, request: Request, comment_id: str) -> Response: The details of the comment that is updated. """ try: - comment = validate_object(Comment, comment_id) - except ObjectDoesNotExist: + request_data = request.data + if anonymous := request_data.get("anonymous"): + anonymous = str_to_bool(anonymous) + if anonymous_to_peers := request_data.get("anonymous_to_peers"): + anonymous_to_peers = str_to_bool(anonymous_to_peers) + if endorsed := request_data.get("endorsed"): + endorsed = str_to_bool(endorsed) + if closed := request_data.get("closed"): + closed = str_to_bool(closed) + comment = update_comment( + comment_id, + request_data.get("body"), + request_data.get("course_id"), + request_data.get("user_id"), + anonymous, + anonymous_to_peers, + endorsed, + closed, + request_data.get("editing_user_id"), + request_data.get("edit_reason_code"), + request_data.get("endorsement_user_id"), + ) + except ForumV2RequestError: return Response( - {"error": "Comment does not exist"}, + {"error": f"Comment does not exist with Id: {comment_id}"}, status=status.HTTP_400_BAD_REQUEST, ) - - data = request.data - update_comment_data: dict[str, Any] = self._get_update_comment_data(data) - if comment: - update_comment_data["edit_history"] = comment.get("edit_history", []) - update_comment_data["original_body"] = comment.get("body") - - Comment().update(comment_id, **update_comment_data) - updated_comment = Comment().get(comment_id) - try: - if updated_comment: - response_data = prepare_comment_api_response( - updated_comment, - exclude_fields=( - ["endorsement", "sk"] - if updated_comment.get("parent_id") - else ["sk"] - ), - ) - return Response(response_data, status=status.HTTP_200_OK) - except ValidationError as error: + except ValidationError as e: return Response( - error.detail, + {"error": e.detail}, status=status.HTTP_400_BAD_REQUEST, ) - return Response( - {"error": "Comment is not updated"}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(comment, status=status.HTTP_200_OK) def delete(self, request: Request, comment_id: str) -> Response: """ @@ -261,46 +147,13 @@ def delete(self, request: Request, comment_id: str) -> Response: The details of the comment that is deleted. """ try: - comment = validate_object(Comment, comment_id) - except ObjectDoesNotExist: + deleted_comment = delete_comment(comment_id) + except ForumV2RequestError: return Response( - {"error": "Comment does not exist"}, + {"error": f"Comment does not exist with Id: {comment_id}"}, status=status.HTTP_400_BAD_REQUEST, ) - data = prepare_comment_api_response( - comment, - exclude_fields=["endorsement", "sk"], - ) - Comment().delete(comment_id) - author_id = comment["author_id"] - course_id = comment["course_id"] - parent_comment_id = data["parent_id"] - if parent_comment_id: - update_stats_for_course(author_id, course_id, replies=-1) - else: - update_stats_for_course(author_id, course_id, responses=-1) - - return Response(data, status=status.HTTP_200_OK) - - def _get_update_comment_data(self, data: dict[str, Any]) -> dict[str, Any]: - """convert request data to a dict excluding empty data""" - - fields = [ - ("body", data.get("body")), - ("course_id", data.get("course_id")), - ("anonymous", str_to_bool(data.get("anonymous", "False"))), - ( - "anonymous_to_peers", - str_to_bool(data.get("anonymous_to_peers", "False")), - ), - ("closed", str_to_bool(data.get("closed", "False"))), - ("endorsed", str_to_bool(data.get("endorsed", "False"))), - ("author_id", data.get("user_id")), - ("editing_user_id", data.get("editing_user_id")), - ("edit_reason_code", data.get("edit_reason_code")), - ("endorsement_user_id", data.get("endorsement_user_id")), - ] - return {field: value for field, value in fields if value is not None} + return Response(deleted_comment, status=status.HTTP_200_OK) class CreateThreadCommentAPIView(APIView): @@ -327,39 +180,28 @@ def post(self, request: Request, thread_id: str) -> Response: The details of the comment that is created. """ try: - thread = validate_object(CommentThread, thread_id) - except ObjectDoesNotExist: + request_data = request.data + comment = create_parent_comment( + thread_id, + request_data["body"], + request_data["user_id"], + request_data["course_id"], + str_to_bool(request_data.get("anonymous", False)), + str_to_bool(request_data.get("anonymous_to_peers", False)), + ) + except ForumV2RequestError: return Response( - {"error": "Thread does not exist"}, + {"error": f"Thread does not exist with Id: {thread_id}"}, status=status.HTTP_400_BAD_REQUEST, ) - data = request.data - try: - validate_comments_request_data(data) - except ValueError as error: + except ValueError as e: return Response( - {"error": str(error)}, + {"error": e}, status=status.HTTP_400_BAD_REQUEST, ) - - comment = create_comment(data, 0, thread_id) - user = Users().get(data["user_id"]) - if user and comment: - mark_as_read(user, thread) - try: - if comment: - response_data = prepare_comment_api_response( - comment, - exclude_fields=["endorsement", "sk"], - ) - return Response(response_data, status=status.HTTP_200_OK) - except ValidationError as error: + except ValidationError as e: return Response( - error.detail, + {"error": e.detail}, status=status.HTTP_400_BAD_REQUEST, ) - - return Response( - {"error": "Comment is not created"}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(comment, status=status.HTTP_200_OK) diff --git a/forum/views/flags.py b/forum/views/flags.py index 96c86485..9f1df8bc 100644 --- a/forum/views/flags.py +++ b/forum/views/flags.py @@ -6,14 +6,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from forum.backends.mongodb import Comment, CommentThread, Users -from forum.backends.mongodb.api import ( - flag_as_abuse, - un_flag_all_as_abuse, - un_flag_as_abuse, -) -from forum.serializers.comment import CommentSerializer -from forum.serializers.thread import ThreadSerializer +from forum.api.flags import update_comment_flag, update_thread_flag +from forum.utils import ForumV2RequestError, str_to_bool class CommentFlagAPIView(APIView): @@ -38,41 +32,15 @@ def put(self, request: Request, comment_id: str, action: str) -> Response: Response: A response with the updated comment data. """ request_data = request.data - user = Users().get(request_data["user_id"]) - comment = Comment().get(comment_id) - if not (user and comment): - return Response( - {"error": "User / Comment doesn't exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if action == "flag": - updated_comment = flag_as_abuse(user, comment) - elif action == "unflag": - if request_data.get("all") and request_data.get("all") is True: - updated_comment = un_flag_all_as_abuse(comment) - else: - updated_comment = un_flag_as_abuse(user, comment) - else: - return Response( - {"error": "Invalid action"}, status=status.HTTP_400_BAD_REQUEST - ) - - if updated_comment is None: - return Response( - {"error": "Failed to update comment"}, - status=status.HTTP_400_BAD_REQUEST, + update_all = str_to_bool(request_data.get("all", False)) + user_id = request_data.get("user_id") + try: + serializer_data = update_comment_flag( + comment_id, action, user_id, update_all ) - - context = { - "id": str(updated_comment["_id"]), - **updated_comment, - "user_id": user["_id"], - "username": user["username"], - "type": "comment", - "thread_id": str(updated_comment.get("comment_thread_id", None)), - } - serializer = CommentSerializer(context) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer_data, status=status.HTTP_200_OK) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) class ThreadFlagAPIView(APIView): @@ -97,39 +65,10 @@ def put(self, request: Request, thread_id: str, action: str) -> Response: Response: A response with the updated thread data. """ request_data = request.data - user = Users().get(request_data["user_id"]) - thread = CommentThread().get(thread_id) - if not (user and thread): - return Response( - {"error": "User / Thread doesn't exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if action == "flag": - updated_thread = flag_as_abuse(user, thread) - elif action == "unflag": - if request_data.get("all"): - updated_thread = un_flag_all_as_abuse(thread) - else: - updated_thread = un_flag_as_abuse(user, thread) - else: - return Response( - {"error": "Invalid action"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if updated_thread is None: - return Response( - {"error": "Failed to update thread"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - context = { - "id": str(updated_thread["_id"]), - **updated_thread, - "user_id": user["_id"], - "username": user["username"], - "type": "thread", - "thread_id": str(updated_thread.get("comment_thread_id", None)), - } - serializer = ThreadSerializer(context) - return Response(serializer.data, status=status.HTTP_200_OK) + update_all = str_to_bool(request_data.get("all", False)) + user_id = request_data.get("user_id") + try: + serializer_data = update_thread_flag(thread_id, action, user_id, update_all) + return Response(serializer_data, status=status.HTTP_200_OK) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/forum/views/pins.py b/forum/views/pins.py index 87496a22..4260ab98 100644 --- a/forum/views/pins.py +++ b/forum/views/pins.py @@ -1,5 +1,6 @@ """Forum Pin/Unpin thread API Views.""" +import logging from typing import Any from rest_framework import status @@ -8,8 +9,10 @@ from rest_framework.response import Response from rest_framework.views import APIView -from forum.backends.mongodb.api import handle_pin_unpin_thread_request -from forum.serializers.thread import ThreadSerializer +from forum.api import pin_thread, unpin_thread +from forum.utils import ForumV2RequestError + +log = logging.getLogger(__name__) class PinThreadAPIView(APIView): @@ -32,10 +35,10 @@ def put(self, request: Request, thread_id: str) -> Response: A response with the updated thread data. """ try: - thread_data: dict[str, Any] = handle_pin_unpin_thread_request( - request.data.get("user_id", ""), thread_id, "pin", ThreadSerializer + thread_data: dict[str, Any] = pin_thread( + request.data.get("user_id", ""), thread_id ) - except ValueError as e: + except ForumV2RequestError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(thread_data, status=status.HTTP_200_OK) @@ -61,13 +64,10 @@ def put(self, request: Request, thread_id: str) -> Response: A response with the updated thread data. """ try: - thread_data: dict[str, Any] = handle_pin_unpin_thread_request( - request.data.get("user_id", ""), - thread_id, - "unpin", - ThreadSerializer, + thread_data: dict[str, Any] = unpin_thread( + request.data.get("user_id", ""), thread_id ) - except ValueError as e: + except ForumV2RequestError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(thread_data, status=status.HTTP_200_OK) diff --git a/forum/views/search.py b/forum/views/search.py index b7b036e5..48f16b47 100644 --- a/forum/views/search.py +++ b/forum/views/search.py @@ -2,7 +2,7 @@ Search API Views """ -from typing import Any, Optional +from typing import Any from rest_framework import status from rest_framework.permissions import AllowAny @@ -10,10 +10,8 @@ from rest_framework.response import Response from rest_framework.views import APIView +from forum.api.search import search_threads from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE -from forum.backends.mongodb.api import handle_threads_query -from forum.search.comment_search import ThreadSearch -from forum.serializers.thread import ThreadSerializer from forum.utils import get_group_ids_from_params @@ -76,39 +74,10 @@ def _validate_and_extract_params(self, request: Request) -> dict[str, Any]: # Group IDs extraction params["group_ids"] = get_group_ids_from_params(data) - return params - - def _get_thread_ids_from_indexes( - self, context: str, group_ids: list[int], params: dict[str, Any], text: str - ) -> tuple[list[str], Optional[str]]: - """ - Retrieve thread IDs based on the search text and suggested corrections if necessary. + params["commentable_id"] = data.get("commentable_id") + params["commentable_ids"] = data.get("commentable_ids") - Args: - context (str): The context in which the search is performed, e.g., "course". - group_ids (list[int]): list of group IDs to filter the search. - params (dict[str, Any]): Query parameters for the search. - text (str): The search text used to find threads. - - Returns: - tuple[Optional[list[str]], Optional[str]]: - - A list of thread IDs that match the search criteria. - - A suggested correction for the search text, or None if no correction is found. - """ - corrected_text: Optional[str] = None - thread_search = ThreadSearch() - - thread_ids = thread_search.get_thread_ids(context, group_ids, params, text) - if not thread_ids: - corrected_text = thread_search.get_suggested_text(text, ["body", "title"]) - if corrected_text: - thread_ids = thread_search.get_thread_ids_with_corrected_text( - context, group_ids, params, corrected_text - ) - if not thread_ids: - corrected_text = None - - return thread_ids, corrected_text + return params def get(self, request: Request) -> Response: """ @@ -122,46 +91,10 @@ def get(self, request: Request) -> Response: """ try: - params = self._validate_and_extract_params(request) + params: dict[str, Any] = self._validate_and_extract_params(request) except ValueError as error: return Response({"error": str(error)}, status=status.HTTP_400_BAD_REQUEST) - thread_ids, corrected_text = self._get_thread_ids_from_indexes( - params["context"], params["group_ids"], request.query_params, params["text"] - ) - - data: dict[str, Any] = handle_threads_query( - thread_ids, - params["user_id"], - params["course_id"], - params["group_ids"], - params["author_id"], - params["thread_type"], - params["flagged"], - params["unread"], - params["unanswered"], - params["unresponded"], - params["count_flagged"], - params["sort_key"], - params["page"], - params["per_page"], - params["context"], - ) - - if collections := data.get("collection"): - thread_serializer = ThreadSerializer( - collections, - many=True, - context={ - "count_flagged": True, - "include_endorsed": True, - "include_read_state": True, - }, - ) - data["collection"] = thread_serializer.data - - if data: - data["corrected_text"] = corrected_text - data["total_results"] = len(thread_ids) - - return Response(data) + search_threads_data = search_threads(**params) + + return Response(search_threads_data) diff --git a/forum/views/subscriptions.py b/forum/views/subscriptions.py index 2c17366d..f3e2d544 100644 --- a/forum/views/subscriptions.py +++ b/forum/views/subscriptions.py @@ -1,24 +1,19 @@ """Subscriptions API Views.""" -from typing import Any - from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from forum.backends.mongodb import CommentThread, Subscriptions, Users -from forum.backends.mongodb.api import ( - find_subscribed_threads, - get_threads, - subscribe_user, - unsubscribe_user, - validate_params, +from forum.api.subscriptions import ( + create_subscription, + delete_subscription, + get_thread_subscriptions, + get_user_subscriptions, ) from forum.pagination import ForumPagination -from forum.serializers.subscriptions import SubscriptionSerializer -from forum.serializers.thread import ThreadSerializer +from forum.utils import ForumV2RequestError class SubscriptionAPIView(APIView): @@ -45,23 +40,11 @@ def post(self, request: Request, user_id: str) -> Response: HTTP_400_BAD_REQUEST: If the user or content does not exist. """ request_data = request.data - user = Users().get(user_id) - source_id = request_data.get("source_id") - if not source_id: - return Response( - {"error": "source_id is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - thread = CommentThread().get(source_id) - if not (user and thread): - return Response( - {"error": "User / Thread doesn't exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - subscription = subscribe_user(user_id, source_id, thread["_type"]) - serializer = SubscriptionSerializer(subscription) - return Response(data=serializer.data, status=status.HTTP_200_OK) + try: + serilized_data = create_subscription(user_id, request_data["source_id"]) + except ForumV2RequestError as e: + return Response(data={"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(data=serilized_data, status=status.HTTP_200_OK) def delete(self, request: Request, user_id: str) -> Response: """ @@ -77,31 +60,12 @@ def delete(self, request: Request, user_id: str) -> Response: Raises: HTTP_400_BAD_REQUEST: If the user or subscription does not exist. """ - params = request.GET.dict() - user = Users().get(user_id) - if not user: - return Response( - {"error": "User doesn't exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if not params.get("source_id"): - return Response( - {"error": "Missing required parameter source_id"}, - status=status.HTTP_400_BAD_REQUEST, - ) - subscription = Subscriptions().get_subscription( - user_id, - params["source_id"], - ) - if not subscription: - return Response( - {"error": "Subscription doesn't exist"}, - status=status.HTTP_404_NOT_FOUND, - ) - - unsubscribe_user(user_id, params["source_id"]) - serializer = SubscriptionSerializer(subscription) - return Response(data=serializer.data, status=status.HTTP_200_OK) + try: + params = request.query_params + serilized_data = delete_subscription(user_id, params["source_id"]) + except ForumV2RequestError as e: + return Response(data={"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(data=serilized_data, status=status.HTTP_200_OK) class UserSubscriptionAPIView(APIView): @@ -128,13 +92,13 @@ def get(self, request: Request, user_id: str) -> Response: HTTP_400_BAD_REQUEST: If the user does not exist. """ params = request.GET.dict() - validations = validate_params(params, user_id) - if validations: - return validations - - thread_ids = find_subscribed_threads(user_id, params["course_id"]) - threads = get_threads(params, user_id, ThreadSerializer, thread_ids) - return Response(data=threads, status=status.HTTP_200_OK) + try: + serilized_data = get_user_subscriptions( + user_id, params["course_id"], params + ) + except ForumV2RequestError as e: + return Response(data={"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(data=serilized_data, status=status.HTTP_200_OK) class ThreadSubscriptionAPIView(APIView): @@ -158,32 +122,7 @@ def get(self, request: Request, thread_id: str) -> Response: Returns: Response: A paginated Response object with the subscription data. """ - query = {} - response = { - "collection": [], - "subscriptions_count": 0, - "page": request.GET.get("page", 1), - "num_pages": 0, - } - query["source_id"] = thread_id - query["source_type"] = "CommentThread" - subscriptions_list = list(Subscriptions().find(query)) - - paginator = self.pagination_class() - paginated_subscriptions: dict[str, Any] | None = paginator.paginate_queryset( - subscriptions_list, - request, - ) - - if not paginated_subscriptions: - return Response(response, status=status.HTTP_200_OK) - - subscriptions = SubscriptionSerializer(paginated_subscriptions, many=True) - subscriptions_count = len(subscriptions.data) - response["collection"] = subscriptions.data - response["subscriptions_count"] = subscriptions_count - response["page"] = request.GET.get("page", 1) - response["num_pages"] = max( - 1, subscriptions_count // int(request.GET.get("per_page", 20)) - ) - return Response(response, status=status.HTTP_200_OK) + page = int(request.GET.get("page", 1)) + per_page = int(request.GET.get("per_page", 20)) + subscriptions_data = get_thread_subscriptions(thread_id, page, per_page) + return Response(subscriptions_data, status=status.HTTP_200_OK) diff --git a/forum/views/threads.py b/forum/views/threads.py index 28519759..893bd2a3 100644 --- a/forum/views/threads.py +++ b/forum/views/threads.py @@ -1,97 +1,26 @@ """Forum Threads API Views.""" import logging -from typing import Any, Optional +from typing import Any -from django.core.exceptions import ObjectDoesNotExist from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ValidationError from rest_framework.views import APIView -from forum.backends.mongodb.api import ( - delete_comments_of_a_thread, - delete_subscriptions_of_a_thread, - get_threads, - mark_as_read, - update_stats_for_course, - validate_object, - validate_params, +from forum.api.threads import ( + create_thread, + delete_thread, + get_thread, + get_user_threads, + update_thread, ) -from forum.backends.mongodb.threads import CommentThread -from forum.backends.mongodb.users import Users -from forum.serializers.thread import ThreadSerializer -from forum.utils import get_int_value_from_collection, str_to_bool +from forum.utils import ForumV2RequestError, str_to_bool log = logging.getLogger(__name__) -def get_thread_data(thread: dict[str, Any]) -> dict[str, Any]: - """Prepare thread data for the api response.""" - _type = str(thread.get("_type", "")).lower() - thread_data = { - **thread, - "id": str(thread.get("_id")), - "type": "thread" if _type == "commentthread" else _type, - "user_id": thread.get("author_id"), - "username": str(thread.get("author_username")), - "comments_count": thread["comment_count"], - } - return thread_data - - -def prepare_thread_api_response( - thread: dict[str, Any], - include_context: Optional[bool] = False, - data_or_params: Optional[dict[str, Any]] = None, - include_data_from_params: Optional[bool] = False, -) -> dict[str, Any] | None: - """Serialize thread data for the api response.""" - thread_data = get_thread_data(thread) - - context = {} - if include_context: - context = { - "include_endorsed": True, - "include_read_state": True, - } - if data_or_params: - if user_id := data_or_params.get("user_id"): - context["user_id"] = user_id - - if include_data_from_params: - thread_data["resp_skip"] = get_int_value_from_collection( - data_or_params, "resp_skip", 0 - ) - thread_data["resp_limit"] = get_int_value_from_collection( - data_or_params, "resp_limit", 100 - ) - params = [ - "recursive", - "with_responses", - "mark_as_read", - "reverse_order", - "merge_question_type_responses", - ] - for param in params: - if value := data_or_params.get(param): - context[param] = str_to_bool(value) - if user_id and (user := Users().get(user_id)): - mark_as_read(user, thread) - - serializer = ThreadSerializer( - data=thread_data, - context=context, - ) - if not serializer.is_valid(raise_exception=True): - log.error(f"validation error in thread API call: {serializer.errors}") - raise ValidationError(serializer.errors) - - return serializer.data - - class ThreadsAPIView(APIView): """ API view to handle operations related to threads. @@ -114,27 +43,18 @@ def get(self, request: Request, thread_id: str) -> Response: Response: A Response object containing the serialized thread data or an error message. """ try: - thread = validate_object(CommentThread, thread_id) - except ObjectDoesNotExist: + params = request.query_params.dict() + data = get_thread(thread_id, params) + except ForumV2RequestError as error: return Response( - {"error": "Thread does not exist"}, + {"error": str(error)}, status=status.HTTP_400_BAD_REQUEST, ) - - params = request.query_params - try: - serialized_data = prepare_thread_api_response(thread, True, params, True) - return Response(serialized_data) - - except ValidationError as error: - return Response( - error.detail, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + return Response(data, status=status.HTTP_200_OK) def delete(self, request: Request, thread_id: str) -> Response: """ - Deletes a thread by it's ID. + Deletes a thread by its ID. Parameters: request (Request): The incoming request. @@ -145,33 +65,14 @@ def delete(self, request: Request, thread_id: str) -> Response: The details of the thread that is deleted. """ try: - thread = validate_object(CommentThread, thread_id) - except ObjectDoesNotExist: + serialized_data = delete_thread(thread_id) + return Response(serialized_data, status=status.HTTP_200_OK) + except ForumV2RequestError as error: return Response( - {"error": "thread does not exist"}, + {"error": str(error)}, status=status.HTTP_400_BAD_REQUEST, ) - delete_comments_of_a_thread(thread_id) - thread = validate_object(CommentThread, thread_id) - - try: - serialized_data = prepare_thread_api_response(thread) - except ValidationError as error: - return Response( - error.detail, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - result = CommentThread().delete(thread_id) - delete_subscriptions_of_a_thread(thread_id) - if result: - if not (thread["anonymous"] or thread["anonymous_to_peers"]): - update_stats_for_course( - thread["author_id"], thread["course_id"], threads=-1 - ) - - return Response(serialized_data, status=status.HTTP_200_OK) - def put(self, request: Request, thread_id: str) -> Response: """ Updates an existing thread. @@ -184,70 +85,16 @@ def put(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is updated. """ - try: - thread = validate_object(CommentThread, thread_id) - except ObjectDoesNotExist: - return Response( - {"error": "thread does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - data = request.data - update_thread_data: dict[str, Any] = self._get_update_thread_data(data) - if thread: - update_thread_data["edit_history"] = thread.get("edit_history", []) - update_thread_data["original_body"] = thread.get("body") - if update_thread_data.get("closed"): - for field_for_closing in ["close_reason_code", "closed_by_id"]: - if field_for_closing not in update_thread_data: - return Response( - {"error": f"{field_for_closing} is not provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) - CommentThread().update(thread_id, **update_thread_data) - updated_thread = CommentThread().get(thread_id) try: - if updated_thread: - serialized_data = prepare_thread_api_response( - updated_thread, - True, - data, - ) - return Response(serialized_data, status=status.HTTP_200_OK) - except ValidationError as error: + serialized_data = update_thread(thread_id, **request.data) + return Response(serialized_data, status=status.HTTP_200_OK) + except ForumV2RequestError as error: return Response( - error.detail, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + {"error": str(error)}, + status=status.HTTP_400_BAD_REQUEST, ) - return Response( - {"error": "Thread is not updated"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - def _get_update_thread_data(self, data: dict[str, Any]) -> dict[str, Any]: - """convert request data to a dict excluding empty data""" - fields = [ - ("title", data.get("title")), - ("body", data.get("body")), - ("course_id", data.get("course_id")), - ("anonymous", str_to_bool(data.get("anonymous", "False"))), - ( - "anonymous_to_peers", - str_to_bool(data.get("anonymous_to_peers", "False")), - ), - ("closed", str_to_bool(data.get("closed", "False"))), - ("commentable_id", data.get("commentable_id", "course")), - ("author_id", data.get("user_id")), - ("editing_user_id", data.get("editing_user_id")), - ("pinned", str_to_bool(data.get("pinned", "False"))), - ("thread_type", data.get("thread_type", "discussion")), - ("edit_reason_code", data.get("edit_reason_code")), - ("close_reason_code", data.get("close_reason_code")), - ("closed_by_id", data.get("closing_user_id")), - ] - return {field: value for field, value in fields if value is not None} - class CreateThreadAPIView(APIView): """ @@ -270,56 +117,21 @@ def post(self, request: Request) -> Response: Response: The details of the thread that is created. """ - data = request.data + try: - self.validate_request_data(data) - except ValueError as error: + params = request.data + if params.get("anonymous"): + params["anonymous"] = str_to_bool(params["anonymous"]) + if params.get("anonymous_to_peers"): + params["anonymous_to_peers"] = str_to_bool(params["anonymous_to_peers"]) + serialized_data = create_thread(**params) + return Response(serialized_data, status=status.HTTP_200_OK) + except (TypeError, ForumV2RequestError) as error: return Response( {"error": str(error)}, status=status.HTTP_400_BAD_REQUEST, ) - thread = self.create_thread(data) - if not (thread["anonymous"] or thread["anonymous_to_peers"]): - update_stats_for_course(thread["author_id"], thread["course_id"], threads=1) - try: - serialized_data = prepare_thread_api_response(thread, True, data) - except ValidationError as error: - return Response( - error.detail, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - return Response(serialized_data, status=status.HTTP_200_OK) - - def validate_request_data(self, data: dict[str, Any]) -> None: - """ - Validates the request data if it exists or not. - - Parameters: - data: request data to validate. - Response: - raise exception if some data does not exists. - """ - fields_to_validate = ["title", "body", "course_id", "user_id"] - for field in fields_to_validate: - if field not in data or not data[field]: - raise ValueError(f"{field} is missing.") - - def create_thread(self, data: dict[str, Any]) -> Any: - """handle thread creation and returns a thread.""" - new_thread_id = CommentThread().insert( - title=data["title"], - body=data["body"], - course_id=data["course_id"], - anonymous=str_to_bool(data.get("anonymous", "False")), - anonymous_to_peers=str_to_bool(data.get("anonymous_to_peers", "False")), - author_id=data["user_id"], - commentable_id=data.get("commentable_id", "course"), - thread_type=data.get("thread_type", "discussion"), - ) - return CommentThread().get(new_thread_id) - class UserThreadsAPIView(APIView): """ @@ -343,18 +155,12 @@ def get(self, request: Request) -> Response: Raises: HTTP_400_BAD_REQUEST: If the user does not exist. """ - params = request.GET.dict() - validations = validate_params(params) - if validations: - return validations - - user_id = params.get("user_id", "") - course_id = params.get("course_id") - thread_filter = { - "_type": {"$in": [CommentThread.content_type]}, - "course_id": {"$in": [course_id]}, - } - filtered_threads = CommentThread().find(thread_filter) - thread_ids = [thread["_id"] for thread in filtered_threads] - threads = get_threads(params, user_id, ThreadSerializer, thread_ids) - return Response(data=threads, status=status.HTTP_200_OK) + try: + params: dict[str, Any] = request.GET.dict() + serialized_data = get_user_threads(**params) + return Response(serialized_data, status=status.HTTP_200_OK) + except (TypeError, ValueError, ForumV2RequestError) as error: + return Response( + {"error": str(error)}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/forum/views/users.py b/forum/views/users.py index 0c3c4fde..5d2d1f94 100644 --- a/forum/views/users.py +++ b/forum/views/users.py @@ -1,6 +1,6 @@ """Subscriptions API Views.""" -import math +import logging from typing import Any from rest_framework import status @@ -10,22 +10,20 @@ from rest_framework.response import Response from rest_framework.views import APIView -from forum.constants import FORUM_DEFAULT_PAGE, FORUM_DEFAULT_PER_PAGE -from forum.backends.mongodb import CommentThread, Contents, Users -from forum.backends.mongodb.api import ( - find_or_create_user, - get_user_by_username, - handle_threads_query, - mark_as_read, - replace_username_in_all_content, - retire_all_content, - unsubscribe_all, - update_all_users_in_course, - user_to_hash, +from forum.api import get_user +from forum.api.users import ( + create_user, + get_user_active_threads, + get_user_course_stats, + mark_thread_as_read, + retire_user, + update_user, + update_username, + update_users_in_course, ) -from forum.serializers.thread import ThreadSerializer -from forum.serializers.users import UserSerializer -from forum.utils import get_group_ids_from_params +from forum.utils import ForumV2RequestError, get_group_ids_from_params, str_to_bool + +log = logging.getLogger(__name__) class UserAPIView(APIView): @@ -35,41 +33,41 @@ class UserAPIView(APIView): def get(self, request: Request, user_id: str) -> Response: """Get user data.""" - params: dict[str, Any] = request.GET.dict() - user = Users().get(user_id) - if not user: - return Response(status=status.HTTP_404_NOT_FOUND) + params = request.GET.dict() + course_id = params.get("course_id", "") group_ids = get_group_ids_from_params(params) - params.update({"group_ids": group_ids}) - hashed_user = user_to_hash(user, params) - serializer = UserSerializer(hashed_user) - return Response(serializer.data, status=status.HTTP_200_OK) + complete = str_to_bool(params.get("complete", False)) + + try: + user_data: dict[str, Any] = get_user( + user_id, group_ids, course_id, complete + ) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_404_NOT_FOUND) + + return Response(user_data, status=status.HTTP_200_OK) def put(self, request: Request, user_id: str) -> Response: """Update user data.""" - params = request.data - user = Users().get(user_id) - username = params.get("username") - user_by_username = get_user_by_username(username) - if user and user_by_username: - if user["external_id"] != user_by_username["external_id"]: - return Response(status=status.HTTP_400_BAD_REQUEST) - elif user_by_username: - return Response(status=status.HTTP_400_BAD_REQUEST) - else: - user_id = find_or_create_user(user_id) - Users().update( - user_id, - username=username, - default_sort_key=params.get("default_sort_key"), - ) + try: + params = request.data + username = params.get("username") + default_sort_key = params.get("default_sort_key") + course_id = params.get("course_id") + group_ids = params.get("group_ids") + complete = params.get("complete") + updated_user = update_user( + user_id, + username, + default_sort_key, + course_id, + group_ids, + complete, + ) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - updated_user = Users().get(user_id) - if not updated_user: - return Response(status=status.HTTP_400_BAD_REQUEST) - hashed_user = user_to_hash(updated_user, params) - serializer = UserSerializer(hashed_user) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(updated_user, status=status.HTTP_200_OK) class UserCreateAPIView(APIView): @@ -86,20 +84,19 @@ def post(self, request: Request) -> Response: {"error": f"Invalid parameter: {key}"}, status=status.HTTP_400_BAD_REQUEST, ) - user_by_id = Users().get(params["id"]) - user_by_username = get_user_by_username(params["username"]) - if user_by_id or user_by_username: - return Response(status=status.HTTP_400_BAD_REQUEST) - - Users().insert( - external_id=params["id"], - username=params["username"], - ) - new_user = Users().get(params["id"]) - if not new_user: - return Response(status=status.HTTP_400_BAD_REQUEST) - hashed_user = user_to_hash(new_user, params) - return Response(hashed_user, status=status.HTTP_200_OK) + try: + data: dict[str, Any] = { + "user_id": params.get("id"), + "username": params.get("username"), + "default_sort_key": params.get("default_sort_key"), + "course_id": params.get("course_id"), + "group_ids": params.get("group_ids"), + "complete": params.get("complete"), + } + user_data = create_user(**data) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(user_data, status=status.HTTP_200_OK) class UserEditAPIView(APIView): @@ -118,14 +115,10 @@ def post(self, request: Request, user_id: str) -> Response: new_username = params.get("new_username") if not new_username: return error_500_response - user = Users().get(user_id) - if not user: - return Response( - {"message": "User not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - Users().update(user_id, username=new_username) - replace_username_in_all_content(user_id, new_username) + try: + update_username(user_id, new_username) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) @@ -147,20 +140,10 @@ def post(self, request: Request, user_id: str) -> Response: retired_username = params.get("retired_username") if not retired_username: return error_500_response - user = Users().get(user_id) - if not user: - return Response( - {"message": "User not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - Users().update( - user_id, - email="", - username=retired_username, - read_states=[], - ) - unsubscribe_all(user_id) - retire_all_content(user_id, retired_username) + try: + retire_user(user_id, retired_username) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) @@ -172,29 +155,17 @@ class UserReadAPIView(APIView): def post(self, request: Request, user_id: str) -> Response: """User read.""" params = request.data - source = params.get("source_id", "") - thread = CommentThread().get(source) - if not thread: - return Response( - {"message": "Source not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - user = Users().get(user_id) - if not user: - return Response( - {"message": "User not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - mark_as_read(user, thread) - user = Users().get(user_id) - if not user: - return Response( - {"message": "User not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - hashed_user = user_to_hash(user, params) - serializer = UserSerializer(hashed_user) - return Response(serializer.data, status=status.HTTP_200_OK) + data = { + "source_id": params.get("source_id", ""), + "complete": params.get("complete"), + "course_id": params.get("course_id"), + "group_ids": params.get("group_ids"), + } + try: + serialized_data = mark_thread_as_read(user_id, **data) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(serialized_data, status=status.HTTP_200_OK) class UserActiveThreadsAPIView(APIView): @@ -204,81 +175,30 @@ class UserActiveThreadsAPIView(APIView): def get(self, request: Request, user_id: str) -> Response: """User active threads.""" - params = request.GET.dict() - course_id = params.get("course_id", None) - if not course_id: - return Response( - {}, - status=status.HTTP_200_OK, - ) - sort_key = params.get("sort_key", "user_activity") - raw_query = bool(sort_key == "user_activity") - count_flagged = bool(params.get("count_flagged", "").lower()) - filter_flagged = bool(params.get("flagged", "").lower()) - active_contents = list( - Contents().get_list( - author_id=user_id, - anonymous=False, - anonymous_to_peers=False, - course_id=course_id, - ) - ) - - if filter_flagged: - active_contents = [ - content - for content in active_contents - if content["abuse_flaggers"] and len(content["abuse_flaggers"]) > 0 - ] - active_contents = sorted( - active_contents, key=lambda x: x["updated_at"], reverse=True - ) - active_thread_ids = [ - ( - content["comment_thread_id"] - if content["_type"] == "Comment" - else content["_id"] - ) - for content in active_contents - ] - active_thread_ids = list(set(active_thread_ids)) - - data = handle_threads_query( - active_thread_ids, - user_id, - course_id, - group_ids=[], - author_id="", - thread_type="", - filter_flagged=False, - filter_unread=bool(params.get("unread")) or False, - filter_unanswered=bool(params.get("unanswered")) or False, - filter_unresponded=bool(params.get("unanswered")) or False, - count_flagged=count_flagged, - sort_key=sort_key, - page=int(request.GET.get("page", FORUM_DEFAULT_PAGE)), - per_page=int(request.GET.get("per_page", FORUM_DEFAULT_PER_PAGE)), - context="course", - raw_query=raw_query, - ) - if collections := data.get("collection"): - thread_serializer = ThreadSerializer( - collections, - many=True, - context={ - "count_flagged": True, - "include_endorsed": True, - "include_read_state": True, - }, - ) - data["collection"] = thread_serializer.data - else: - collection = data.get("result", []) - for thread in collection: - thread["_id"] = str(thread.pop("_id")) - thread["type"] = str(thread.get("_type", "")).lower() - data["collection"] = ThreadSerializer(collection, many=True).data - return Response(data) + params: dict[str, Any] = request.GET.dict() + course_id = params.pop("course_id", None) + + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if flagged := params.get("flagged"): + params["flagged"] = str_to_bool(flagged) + if unread := params.get("unread"): + params["unread"] = str_to_bool(unread) + if unanswered := params.get("unanswered"): + params["unanswered"] = str_to_bool(unanswered) + if unresponded := params.get("unresponded"): + params["unresponded"] = str_to_bool(unresponded) + if count_flagged := params.get("count_flagged"): + params["count_flagged"] = str_to_bool(count_flagged) + if group_id := params.get("group_id"): + params["group_id"] = int(group_id) + try: + serialized_data = get_user_active_threads(user_id, course_id, **params) + except ForumV2RequestError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(serialized_data) class UserCourseStatsAPIView(APIView): @@ -286,142 +206,23 @@ class UserCourseStatsAPIView(APIView): permission_classes = (AllowAny,) - def _create_pipeline( - self, course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] - ) -> list[dict[str, Any]]: - """Get pipeline for course stats api.""" - pipeline: list[dict[str, Any]] = [ - {"$match": {"course_stats.course_id": course_id}}, - {"$project": {"username": 1, "course_stats": 1}}, - {"$unwind": "$course_stats"}, - {"$match": {"course_stats.course_id": course_id}}, - {"$sort": sort_criterion}, - { - "$facet": { - "pagination": [{"$count": "total_count"}], - "data": [ - {"$skip": (page - 1) * per_page}, - {"$limit": per_page}, - ], - } - }, - ] - return pipeline - - def _get_sort_criterion(self, sort_by: str) -> dict[str, Any]: - """Get sort criterion based on sort_by parameter.""" - if sort_by == "flagged": - return { - "course_stats.active_flags": -1, - "course_stats.inactive_flags": -1, - "username": -1, - } - elif sort_by == "recency": - return { - "course_stats.last_activity_at": -1, - "username": -1, - } - else: - return { - "course_stats.threads": -1, - "course_stats.responses": -1, - "course_stats.replies": -1, - "username": -1, - } - - def _get_paginated_stats( - self, course_id: str, page: int, per_page: int, sort_criterion: dict[str, Any] - ) -> dict[str, Any]: - """Get paginated stats for a course.""" - pipeline = self._create_pipeline(course_id, page, per_page, sort_criterion) - return list(Users().aggregate(pipeline))[0] - - def _get_user_data( - self, user_stats: dict[str, Any], exclude_from_stats: list[str] - ) -> dict[str, Any]: - """Get user data from user stats.""" - user_data = {"username": user_stats["username"]} - for k, v in user_stats["course_stats"].items(): - if k not in exclude_from_stats: - user_data[k] = v - return user_data - - def _get_stats_for_usernames( - self, course_id: str, usernames: list[str] - ) -> list[dict[str, Any]]: - """Get stats for specific usernames.""" - users = Users().get_list() - stats_query = [] - for user in users: - if user["username"] not in usernames: - continue - course_stats = user["course_stats"] - if course_stats: - for course_stat in course_stats: - if course_stat["course_id"] == course_id: - stats_query.append( - {"username": user["username"], "course_stats": course_stat} - ) - break - return sorted(stats_query, key=lambda u: usernames.index(u["username"])) - def get(self, request: Request, course_id: str) -> Response: """Get user course stats.""" - params = request.GET.dict() - page = int(request.GET.get("page", FORUM_DEFAULT_PAGE)) - per_page = int(request.GET.get("per_page", FORUM_DEFAULT_PER_PAGE)) - with_timestamps = bool(params.get("with_timestamps", False)) - sort_by = params.get("sort_key", "") - usernames_list = params.get("usernames") - data = [] - usernames = None - if usernames_list: - usernames = usernames_list.split(",") - - sort_criterion = self._get_sort_criterion(sort_by) - exclude_from_stats = ["_id", "course_id"] - if not with_timestamps: - exclude_from_stats.append("last_activity_at") - - if not usernames: - paginated_stats = self._get_paginated_stats( - course_id, page, per_page, sort_criterion - ) - num_pages = 0 - page = 0 - total_count = 0 - if paginated_stats.get("pagination"): - total_count = paginated_stats["pagination"][0]["total_count"] - num_pages = max(1, math.ceil(total_count / per_page)) - data = [ - self._get_user_data(user_stats, exclude_from_stats) - for user_stats in paginated_stats["data"] - ] - else: - stats_query = self._get_stats_for_usernames(course_id, usernames) - total_count = len(stats_query) - num_pages = 1 - data = [ - { - "username": user_stats["username"], - **{ - k: v - for k, v in user_stats["course_stats"].items() - if k not in exclude_from_stats - }, - } - for user_stats in stats_query - ] - - response = { - "user_stats": data, - "num_pages": num_pages, - "page": page, - "count": total_count, - } + params: dict[str, Any] = request.GET.dict() + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if with_timestamps := params.get("with_timestamps"): + params["with_timestamps"] = str_to_bool(with_timestamps) + + response = get_user_course_stats( + course_id, + **params, + ) return Response(response, status=status.HTTP_200_OK) def post(self, request: Request, course_id: str) -> Response: """Update user stats for a course.""" - updated_users = update_all_users_in_course(course_id) - return Response({"user_count": len(updated_users)}, status=status.HTTP_200_OK) + updated_users = update_users_in_course(course_id) + return Response(updated_users, status=status.HTTP_200_OK) diff --git a/forum/views/votes.py b/forum/views/votes.py index c3799dfc..2153c5cd 100644 --- a/forum/views/votes.py +++ b/forum/views/votes.py @@ -2,22 +2,18 @@ Vote Views """ -from typing import Any - from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from forum.backends.mongodb import Comment, CommentThread, Users -from forum.backends.mongodb.api import ( - downvote_content, - remove_vote, - upvote_content, +from forum.api.votes import ( + delete_comment_vote, + delete_thread_vote, + update_comment_votes, + update_thread_votes, ) -from forum.serializers.comment import CommentSerializer -from forum.serializers.thread import ThreadSerializer -from forum.serializers.votes import VotesInputSerializer +from forum.utils import ForumV2RequestError class ThreadVoteView(APIView): @@ -45,60 +41,6 @@ class ThreadVoteView(APIView): } """ - def _get_thread_and_user( - self, thread_id: str, user_id: str - ) -> tuple[dict[str, Any], dict[str, Any]]: - """ - Fetches the thread and user based on provided IDs. - - Args: - thread_id (str): The ID of the thread. - user_id (str): The ID of the user. - - Returns: - tuple: The thread and user objects. - - Raises: - ValueError: If the thread or user is not found. - """ - thread = CommentThread().get(_id=thread_id) - if not thread: - raise ValueError("Thread not found") - - user = Users().get(_id=user_id) - if not user: - raise ValueError("User not found") - - return thread, user - - def _prepare_response( - self, thread: dict[str, Any], user: dict[str, Any] - ) -> dict[str, Any]: - """ - Prepares the serialized response data after voting. - - Args: - thread (dict): The thread data. - user (dict): The user data. - - Returns: - dict: The serialized response data. - - Raises: - ValueError: If serialization fails. - """ - context = { - "id": str(thread["_id"]), - **thread, - "user_id": user["_id"], - "username": user["username"], - "type": "thread", - } - serializer = ThreadSerializer(data=context) - if not serializer.is_valid(): - raise ValueError(serializer.errors) - return serializer.data - def put(self, request: Request, thread_id: str) -> Response: """ Handles the upvote or downvote on a thread. @@ -110,25 +52,14 @@ def put(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ - vote_serializer = VotesInputSerializer(data=request.data) - - if not vote_serializer.is_valid(): - return Response(vote_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - try: - thread, user = self._get_thread_and_user(thread_id, request.data["user_id"]) - except ValueError as e: + thread_response = update_thread_votes( + thread_id, request.data["user_id"], request.data["value"] + ) + except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - if vote_serializer.data["value"] == "up": - is_updated = upvote_content(thread, user) - else: - is_updated = downvote_content(thread, user) - - if is_updated: - thread = CommentThread().get(_id=thread_id) or {} - - return Response(self._prepare_response(thread, user)) + return Response(thread_response, status=status.HTTP_200_OK) def delete(self, request: Request, thread_id: str) -> Response: """ @@ -142,16 +73,12 @@ def delete(self, request: Request, thread_id: str) -> Response: Response: The HTTP response with the result of the remove vote operation. """ try: - thread, user = self._get_thread_and_user( - thread_id, request.query_params.get("user_id", "") - ) - except ValueError as e: + user_id = request.query_params.get("user_id", "") + thread_response = delete_thread_vote(thread_id, user_id) + except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - if remove_vote(thread, user): - thread = CommentThread().get(_id=thread_id) or {} - - return Response(self._prepare_response(thread, user)) + return Response(thread_response, status=status.HTTP_200_OK) class CommentVoteView(APIView): @@ -179,61 +106,6 @@ class CommentVoteView(APIView): } """ - def _get_comment_and_user( - self, comment_id: str, user_id: str - ) -> tuple[dict[str, Any], dict[str, Any]]: - """ - Fetches the comment and user based on provided IDs. - - Args: - comment_id (str): The ID of the comment. - user_id (str): The ID of the user. - - Returns: - tuple: The comment and user objects. - - Raises: - ValueError: If the comment or user is not found. - """ - comment = Comment().get(_id=comment_id) - if not comment: - raise ValueError("Comment not found") - - user = Users().get(_id=user_id) - if not user: - raise ValueError("User not found") - - return comment, user - - def _prepare_response( - self, comment: dict[str, Any], user: dict[str, Any] - ) -> dict[str, Any]: - """ - Prepares the serialized response data after voting. - - Args: - comment (dict): The comment data. - user (dict): The user data. - - Returns: - dict: The serialized response data. - - Raises: - ValueError: If serialization fails. - """ - context = { - "id": str(comment["_id"]), - **comment, - "user_id": user["_id"], - "username": user["username"], - "type": "comment", - "thread_id": str(comment.get("comment_thread_id", None)), - } - serializer = CommentSerializer(data=context) - if not serializer.is_valid(): - raise ValueError(serializer.errors) - return serializer.data - def put(self, request: Request, comment_id: str) -> Response: """ Handles the upvote or downvote on a comment. @@ -245,26 +117,14 @@ def put(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ - vote_serializer = VotesInputSerializer(data=request.data) - if not vote_serializer.is_valid(): - return Response(vote_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - try: - comment, user = self._get_comment_and_user( - comment_id, request.data.get("user_id", "") + comment_response = update_comment_votes( + comment_id, request.data["user_id"], request.data["value"] ) - except ValueError as e: + except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - if vote_serializer.data["value"] == "up": - is_updated = upvote_content(comment, user) - else: - is_updated = downvote_content(comment, user) - - if is_updated: - comment = Comment().get(_id=comment_id) or {} - - return Response(self._prepare_response(comment, user)) + return Response(comment_response, status=status.HTTP_200_OK) def delete(self, request: Request, comment_id: str) -> Response: """ @@ -278,13 +138,9 @@ def delete(self, request: Request, comment_id: str) -> Response: Response: The HTTP response with the result of the remove vote operation. """ try: - comment, user = self._get_comment_and_user( - comment_id, request.query_params.get("user_id", "") - ) - except ValueError as e: + user_id = request.query_params.get("user_id", "") + comment_response = delete_comment_vote(comment_id, user_id) + except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - if remove_vote(comment, user): - comment = Comment().get(_id=comment_id) or {} - - return Response(self._prepare_response(comment, user)) + return Response(comment_response, status=status.HTTP_200_OK) diff --git a/tests/e2e/test_search.py b/tests/e2e/test_search.py index e9bf761e..ea52e749 100644 --- a/tests/e2e/test_search.py +++ b/tests/e2e/test_search.py @@ -256,49 +256,64 @@ def create_threads_and_comments_for_filter_tests( return threads_ids, threads_comments -# The test covers all the filters and making this modular leads to more complex structure. -# pylint: disable=too-many-statements -def test_filter_threads(api_client: APIClient) -> None: - """ - Test various filtering options for threads, including course_id, context, flagged, unanswered, group_id, - commentable_id, and combinations of these filters. Asserts that the correct threads are returned for each filter. - """ +def assert_response_contains( + response: Response, expected_indexes: list[int], threads_ids: list[str] +) -> None: + """Assert that the response contains the expected thread IDs.""" + assert response.status_code == 200 + threads = response.json()["collection"] + expected_ids = {threads_ids[i] for i in expected_indexes} + actual_ids = {thread["id"] for thread in threads} + assert actual_ids == expected_ids, f"Expected {expected_ids}, but got {actual_ids}" + + +def test_filter_threads_by_course_id(api_client: APIClient) -> None: + """Test filtering threads by course_id.""" course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" - user_id = Users().insert("1", username="user1", email="example@test.com") - threads_ids, threads_comments = create_threads_and_comments_for_filter_tests( + threads_ids, _ = create_threads_and_comments_for_filter_tests( course_id_0, course_id_1 ) - refresh_elastic_search_indices() - # Test filtering by course_id - def assert_response_contains( - response: Response, expected_indexes: list[int] - ) -> None: - assert response.status_code == 200 - threads = response.json()["collection"] - expected_ids = {threads_ids[i] for i in expected_indexes} - actual_ids = {thread["id"] for thread in threads} - assert ( - actual_ids == expected_ids - ), f"Expected {expected_ids}, but got {actual_ids}" - - # Test filtering by course_id params = {"text": "text", "course_id": course_id_0} response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(30) if i % 2 == 0]) + assert_response_contains( + response, [i for i in range(30) if i % 2 == 0], threads_ids + ) + + +def test_filter_threads_by_context(api_client: APIClient) -> None: + """Test filtering threads by context.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # # Test filtering by context params = {"text": "text", "context": "standalone"} response = perform_search_query(api_client, params) - assert_response_contains(response, list(range(30, 35))) + assert_response_contains(response, list(range(30, 35)), threads_ids) + + +def test_filter_threads_by_unread(api_client: APIClient) -> None: + """Test filtering threads by unread status.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + user_id = Users().insert("1", username="user1", email="example@test.com") + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering with unread filter user = Users().get(_id=user_id) or {} thread = CommentThread().get(_id=threads_ids[0]) or {} mark_as_read(user, thread) + params = { "text": "text", "course_id": course_id_0, @@ -306,19 +321,41 @@ def assert_response_contains( "unread": "True", } response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(1, 30) if i % 2 == 0]) + assert_response_contains( + response, [i for i in range(1, 30) if i % 2 == 0], threads_ids + ) + + +def test_filter_threads_by_flagged(api_client: APIClient) -> None: + """Test filtering threads by flagged status.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering with flagged filter params = {"text": "text", "course_id": course_id_0, "flagged": "True"} response = perform_search_query(api_client, params) - assert_response_contains(response, [0]) + assert_response_contains(response, [0], threads_ids) + + +def test_filter_threads_by_unanswered(api_client: APIClient) -> None: + """Test filtering threads by unanswered status.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, threads_comments = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering with unanswered filter params = {"text": "text", "course_id": course_id_0, "unanswered": "True"} response = perform_search_query(api_client, params) - assert_response_contains(response, [0, 2, 4]) + assert_response_contains(response, [0, 2, 4], threads_ids) - # Test filtering with unanswered filter and group_id + # Test with group_id params = { "text": "text", "course_id": course_id_0, @@ -326,7 +363,7 @@ def assert_response_contains( "group_id": "2", } response = perform_search_query(api_client, params) - assert_response_contains(response, [0, 2]) + assert_response_contains(response, [0, 2], threads_ids) params = { "text": "text", @@ -335,36 +372,73 @@ def assert_response_contains( "group_id": "4", } response = perform_search_query(api_client, params) - assert_response_contains(response, [0, 4]) + assert_response_contains(response, [0, 4], threads_ids) + # Test after endorsing a comment comment = threads_comments[threads_ids[4]][0] Comment().update(comment_id=comment, endorsed=True) refresh_elastic_search_indices() response = perform_search_query(api_client, params) - assert_response_contains(response, [0]) + assert_response_contains(response, [0], threads_ids) + + +def test_filter_threads_by_commentable_id(api_client: APIClient) -> None: + """Test filtering threads by commentable_id.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering by commentable_id params = {"text": "text", "commentable_id": "commentable0"} response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(30) if i % 3 == 0]) + assert_response_contains( + response, [i for i in range(30) if i % 3 == 0], threads_ids + ) - # Test filtering by commentable_ids params = {"text": "text", "commentable_ids": "commentable0,commentable1"} response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(30) if i % 3 in [0, 1]]) + assert_response_contains( + response, [i for i in range(30) if i % 3 in [0, 1]], threads_ids + ) + + +def test_filter_threads_by_group_id(api_client: APIClient) -> None: + """Test filtering threads by group_id.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering by group_id params = {"text": "text", "group_id": "1"} response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(30) if i % 5 in [0, 1]]) + assert_response_contains( + response, [i for i in range(30) if i % 5 in [0, 1]], threads_ids + ) - # Test filtering by group_ids params = {"text": "text", "group_ids": "1,2"} response = perform_search_query(api_client, params) - assert_response_contains(response, [i for i in range(30) if i % 5 in [0, 1, 2]]) + assert_response_contains( + response, [i for i in range(30) if i % 5 in [0, 1, 2]], threads_ids + ) + + +def test_filter_threads_combined(api_client: APIClient) -> None: + """Test filtering threads with multiple filters combined.""" + course_id_0 = "course-v1:Arbisoft+SE002+2024_S2" + course_id_1 = "course-v1:Arbisoft+SE003+2024_S2" + + threads_ids, _ = create_threads_and_comments_for_filter_tests( + course_id_0, course_id_1 + ) + refresh_elastic_search_indices() - # Test filtering by all filters combined params = { "text": "text", "course_id": course_id_0, @@ -372,7 +446,7 @@ def assert_response_contains( "group_id": "1", } response = perform_search_query(api_client, params) - assert_response_contains(response, [0, 6]) + assert_response_contains(response, [0, 6], threads_ids) def test_pagination(api_client: APIClient) -> None: diff --git a/tests/test_backends/test_mysql/test_api.py b/tests/test_backends/test_mysql/test_api.py index 46b6d321..ac568bdc 100644 --- a/tests/test_backends/test_mysql/test_api.py +++ b/tests/test_backends/test_mysql/test_api.py @@ -3,10 +3,7 @@ import pytest from django.contrib.auth import get_user_model -from forum.backends.mysql.models import ( - AbuseFlagger, - CommentThread, -) +from forum.backends.mysql.models import AbuseFlagger, CommentThread from forum.backends.mysql.api import ( flag_as_abuse, un_flag_all_as_abuse, diff --git a/tests/test_views/test_comments.py b/tests/test_views/test_comments.py index 90306ee6..2cdb0745 100644 --- a/tests/test_views/test_comments.py +++ b/tests/test_views/test_comments.py @@ -166,15 +166,24 @@ def test_returns_400_when_comment_does_not_exist(api_client: APIClient) -> None: incorrect_comment_id = "66c42d4aa3a68c001c6c22db" response = api_client.get_json(f"/api/v2/comments/{incorrect_comment_id}", {}) assert response.status_code == 400 - assert response.json() == {"error": "Comment does not exist"} - response = api_client.put_json(f"/api/v2/comments/{incorrect_comment_id}", data={}) + assert response.json() == { + "error": f"Comment does not exist with Id: {incorrect_comment_id}" + } + + response = api_client.put_json( + f"/api/v2/comments/{incorrect_comment_id}", data={"body": "new_body"} + ) assert response.status_code == 400 - assert response.json() == {"error": "Comment does not exist"} + assert response.json() == { + "error": f"Comment does not exist with Id: {incorrect_comment_id}" + } response = api_client.delete_json(f"/api/v2/comments/{incorrect_comment_id}") assert response.status_code == 400 - assert response.json() == {"error": "Comment does not exist"} + assert response.json() == { + "error": f"Comment does not exist with Id: {incorrect_comment_id}" + } def test_updates_body_correctly(api_client: APIClient) -> None: diff --git a/tests/test_views/test_subscriptions.py b/tests/test_views/test_subscriptions.py index cfe4a4f0..62f6f9cf 100644 --- a/tests/test_views/test_subscriptions.py +++ b/tests/test_views/test_subscriptions.py @@ -116,7 +116,7 @@ def test_unsubscribe_thread(api_client: APIClient) -> None: response = api_client.delete( f"/api/v2/users/{user_id}/subscriptions?source_id={comment_thread_id}" ) - assert response.status_code == 404 + assert response.status_code == 400 def test_get_subscribed_threads_with_pagination(api_client: APIClient) -> None: diff --git a/tests/test_views/test_threads.py b/tests/test_views/test_threads.py index 1b9bd05b..a6c06815 100644 --- a/tests/test_views/test_threads.py +++ b/tests/test_views/test_threads.py @@ -165,7 +165,6 @@ def test_update_thread_not_exist(api_client: APIClient) -> None: }, ) assert response.status_code == 400 - assert response.data["error"] == "thread does not exist" def test_unicode_data(api_client: APIClient) -> None: @@ -212,7 +211,6 @@ def test_delete_thread_not_exist(api_client: APIClient) -> None: wrong_thread_id = "66cd75eba3a68c001d51927b" response = api_client.delete_json(f"/api/v2/threads/{wrong_thread_id}") assert response.status_code == 400 - assert response.data["error"] == "thread does not exist" def test_invalide_data(api_client: APIClient) -> None: @@ -221,10 +219,6 @@ def test_invalide_data(api_client: APIClient) -> None: response = api_client.get_json("/api/v2/threads", {}) assert response.status_code == 400 - params = {"course_id": "course1", "invalid": "zyx"} - response = api_client.get_json("/api/v2/threads", params) - assert response.status_code == 400 - params = {"course_id": "course1", "user_id": "4"} response = api_client.get_json("/api/v2/threads", params) assert response.status_code == 400 @@ -445,15 +439,17 @@ def test_filter_by_post_type(api_client: APIClient) -> None: def test_filter_unanswered_questions(api_client: APIClient) -> None: """Test filter unanswered questions through get thread API.""" - _, thread1 = setup_models(thread_type="question") - _, thread2 = setup_models("2", "user2", thread_type="question") + course_id = "course1" + username = "user1" + user_id_1, thread1 = setup_models("1", "user1", thread_type="question") + user_id_2, thread2 = setup_models("2", "user2", thread_type="question") CommentThread().insert( title="Thread 3", body="Thread 3", - course_id="course1", + course_id=course_id, commentable_id="CommentThread", - author_id="1", - author_username="user1", + author_id=user_id_1, + author_username=username, abuse_flaggers=[], historical_abuse_flaggers=[], thread_type="question", @@ -465,15 +461,33 @@ def test_filter_unanswered_questions(api_client: APIClient) -> None: results = response.json()["collection"] assert len(results) == 3 - api_client.put_json( - f"/api/v2/threads/{thread1}", - data={"endorsed": True}, + comment_id_1 = Comment().insert( + body="

Thread 1 Comment

", + course_id=course_id, + author_id=user_id_1, + comment_thread_id=thread1, + author_username=username, ) - api_client.put_json( - f"/api/v2/threads/{thread2}", - data={"endorsed": True}, + comment_id_2 = Comment().insert( + body="

Thread 2 Comment

", + course_id=course_id, + author_id=user_id_2, + comment_thread_id=thread2, + author_username=username, ) + Comment().update(comment_id=comment_id_1, endorsed=True) + Comment().update(comment_id=comment_id_2, endorsed=True) + + # api_client.put_json( + # f"/api/v2/threads/{thread1}", + # data={"endorsed": True}, + # ) + # api_client.put_json( + # f"/api/v2/threads/{thread2}", + # data={"endorsed": True}, + # ) + params = {"course_id": "course1", "unanswered": True} response = api_client.get_json("/api/v2/threads", params) assert response.status_code == 200 diff --git a/tests/test_views/test_users.py b/tests/test_views/test_users.py index bfb2aaee..c67df5de 100644 --- a/tests/test_views/test_users.py +++ b/tests/test_views/test_users.py @@ -1,8 +1,8 @@ """Tests for Users apis.""" -from forum.constants import RETIRED_BODY, RETIRED_TITLE from forum.backends.mongodb import Comment, CommentThread, Contents, Users from forum.backends.mongodb.api import subscribe_user, upvote_content +from forum.constants import RETIRED_BODY, RETIRED_TITLE from test_utils.client import APIClient @@ -299,7 +299,7 @@ def test_attempts_to_replace_username_of_non_existent_user( response = api_client.post_json( "/api/v2/users/1234/replace_username", data={"new_username": new_username} ) - assert response.status_code == 404 + assert response.status_code == 400 def test_attempts_to_replace_username_and_username_on_content( @@ -367,7 +367,7 @@ def test_attempts_to_retire_non_existent_user(api_client: APIClient) -> None: f"/api/v2/users/{user_id}/retire", data={"retired_username": retired_username}, ) - assert response.status_code == 404 + assert response.status_code == 400 def test_retire_user(api_client: APIClient) -> None: