diff --git a/forum/models/comments.py b/forum/models/comments.py index a84f5bf2..fbb5f56c 100644 --- a/forum/models/comments.py +++ b/forum/models/comments.py @@ -6,6 +6,7 @@ from bson import ObjectId from forum.models.contents import BaseContents +from forum.models.users import Users class Comment(BaseContents): @@ -24,9 +25,10 @@ def insert( self, body: str, course_id: str, - comment_thread_id: str, author_id: str, - author_username: str, + parent_id: Optional[str] = None, + comment_thread_id: Optional[str] = None, + author_username: Optional[str] = None, anonymous: bool = False, anonymous_to_peers: bool = False, depth: int = 0, @@ -53,18 +55,18 @@ def insert( Returns: str: The ID of the inserted document. """ - if abuse_flaggers is None: - abuse_flaggers = [] - if historical_abuse_flaggers is None: - historical_abuse_flaggers = [] + parent_comment = parent_id and self.get(parent_id) + parent_child_count = parent_comment and parent_comment.get("child_count") or 0 + if parent_comment and not comment_thread_id: + comment_thread_id = parent_comment.get("comment_thread_id") date = datetime.now() comment_data = { "votes": self.get_votes_dict(up=[], down=[]), "visible": visible, - "abuse_flaggers": abuse_flaggers, - "historical_abuse_flaggers": historical_abuse_flaggers, - "parent_ids": [], + "abuse_flaggers": abuse_flaggers or [], + "historical_abuse_flaggers": historical_abuse_flaggers or [], + "parent_ids": [ObjectId(parent_id)] if parent_id else [], "at_position_list": [], "body": body, "course_id": course_id, @@ -72,15 +74,18 @@ def insert( "endorsed": False, "anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers, + "parent_id": ObjectId(parent_id), "author_id": author_id, "comment_thread_id": ObjectId(comment_thread_id), "child_count": 0, "depth": depth, - "author_username": author_username, + "author_username": author_username or self.get_author_username(author_id), "created_at": date, "updated_at": date, } result = self._collection.insert_one(comment_data) + if parent_id: + self.update(parent_id, child_count=parent_child_count + 1) return str(result.inserted_id) def update( @@ -101,6 +106,12 @@ def update( endorsed: Optional[bool] = None, 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, ) -> int: """ Updates a comment document in the database. @@ -142,15 +153,58 @@ def update( ("endorsed", endorsed), ("child_count", child_count), ("depth", depth), + ("closed", closed), ] update_data: Dict[str, Any] = { field: value for field, value in fields if value is not None } + if endorsed and endorsement_user_id: + update_data["endorsement"] = { + "user_id": endorsement_user_id, + "time": datetime.now(), + } - date = datetime.now() - update_data["updated_at"] = date + if editing_user_id: + edit_history = [] if edit_history is None else edit_history + edit_history.append( + { + "original_body": original_body, + "reason_code": edit_reason_code, + "editor_username": self.get_author_username(editing_user_id), + "created_at": datetime.now(), + } + ) + update_data["edit_history"] = edit_history + + update_data["updated_at"] = datetime.now() result = self._collection.update_one( {"_id": ObjectId(comment_id)}, {"$set": update_data}, ) return result.modified_count + + def delete(self, _id: str) -> int: + """ + Deletes a comment from the database based on the id. + + Args: + _id: The ID of the comment. + + Returns: + The number of comments deleted. + """ + comment = self.get(_id) + parent_comment_id = comment and comment.get("parent_id") + parent_comment = parent_comment_id and self.get(parent_comment_id) + parent_comment_child_count = parent_comment and parent_comment.get( + "child_count", + ) + result = self._collection.delete_one({"_id": ObjectId(_id)}) + if parent_comment_id and parent_comment_child_count: + self.update(parent_comment_id, child_count=parent_comment_child_count - 1) + return result.deleted_count + + def get_author_username(self, author_id: str) -> str | None: + """Return username for the respective author_id(user_id)""" + user = Users().get(author_id) + return user.get("username") if user else None diff --git a/forum/serializers/comment.py b/forum/serializers/comment.py index 2e1558fb..697a0b6e 100644 --- a/forum/serializers/comment.py +++ b/forum/serializers/comment.py @@ -7,6 +7,28 @@ from rest_framework import serializers from forum.serializers.contents import UserContentSerializer +from forum.serializers.custom_datetime import CustomDateTimeField + + +class EndorsementSerializer(serializers.Serializer[dict[str, Any]]): + """ + Serializer for handling endorsement of a comment + + Attributes: + user_id (str): The endorsement user id. + time (datetime): The timestamp of when the user has endorsed the comment. + """ + + user_id = serializers.CharField() + time = CustomDateTimeField() + + def create(self, validated_data: dict[str, Any]) -> Any: + """Raise NotImplementedError""" + raise NotImplementedError + + def update(self, instance: Any, validated_data: dict[str, Any]) -> Any: + """Raise NotImplementedError""" + raise NotImplementedError class UserCommentSerializer(UserContentSerializer): @@ -21,13 +43,34 @@ class UserCommentSerializer(UserContentSerializer): thread_id (str): The ID of the thread the comment belongs to. parent_id (str or None): The ID of the parent comment, if any. child_count (int): The number of child comments nested under this comment. + sk (str or None): sk field, has ids data in it. + endorsement: saves endorsement data. """ endorsed = serializers.BooleanField(default=False) depth = serializers.IntegerField(default=0) thread_id = serializers.CharField() - parent_id = serializers.CharField(default=None) + parent_id = serializers.CharField(default=None, allow_null=True) child_count = serializers.IntegerField(default=0) + sk = serializers.SerializerMethodField() + endorsement = EndorsementSerializer(default=None, required=False, allow_null=True) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + exclude_fields = kwargs.pop("exclude_fields", None) + super().__init__(*args, **kwargs) + if exclude_fields: + for field in exclude_fields: + self.fields.pop(field, None) + + def get_sk(self, obj: dict[str, Any]) -> str: + """Return sk field""" + is_child = obj.get("parent_id") + if is_child is not None: + return "{parent_id}-{id}".format( + parent_id=obj.get("parent_id"), id=obj.get("_id") + ) + else: + return "{id}".format(id=obj.get("_id")) def create(self, validated_data: Dict[str, Any]) -> Any: """Raise NotImplementedError""" diff --git a/forum/serializers/contents.py b/forum/serializers/contents.py index 0652243e..22ebf14d 100644 --- a/forum/serializers/contents.py +++ b/forum/serializers/contents.py @@ -126,14 +126,16 @@ class UserContentSerializer(serializers.Serializer[dict[str, Any]]): course_id = serializers.CharField() anonymous = serializers.BooleanField(default=False) anonymous_to_peers = serializers.BooleanField(default=False) - created_at = CustomDateTimeField() - updated_at = CustomDateTimeField() - at_position_list = serializers.ListField(default=[]) + created_at = CustomDateTimeField(allow_null=True) + updated_at = CustomDateTimeField(allow_null=True) + at_position_list = serializers.ListField(allow_null=True) 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(default=[]) + abuse_flaggers = serializers.ListField( + child=serializers.CharField(), allow_null=True + ) edit_history = EditHistorySerializer(default=[], many=True) closed = serializers.BooleanField(default=False) type = serializers.CharField() diff --git a/forum/urls.py b/forum/urls.py index 44c5bd5e..a623942d 100644 --- a/forum/urls.py +++ b/forum/urls.py @@ -4,6 +4,7 @@ from django.urls import include, path +from forum.views.comments import CommentsAPIView from forum.views.flags import CommentFlagAPIView, ThreadFlagAPIView from forum.views.pins import PinThreadAPIView, UnpinThreadAPIView from forum.views.proxy import ForumProxyAPIView @@ -11,7 +12,7 @@ from forum.views.votes import CommentVoteView, ThreadVoteView api_patterns = [ - # Thread APIs + # thread votes APIs path( "threads//votes", ThreadVoteView.as_view(), @@ -22,7 +23,7 @@ CommentVoteView.as_view(), name="comment-vote", ), - # Comment APIs + # abuse comment/thread APIs path( "comments//abuse_", CommentFlagAPIView.as_view(), @@ -33,13 +34,20 @@ ThreadFlagAPIView.as_view(), name="thread-flags-api", ), - # Pin/Unpin thread APIs + # pin/unpin thread APIs path("threads//pin", PinThreadAPIView.as_view(), name="pin-thread"), path( "threads//unpin", UnpinThreadAPIView.as_view(), name="unpin-thread", ), + # comments API + path( + "comments/", + CommentsAPIView.as_view(), + name="comments-api", + ), + # search threads API path( "search/threads", SearchThreadsView.as_view(), diff --git a/forum/utils.py b/forum/utils.py index a63d437f..34baf106 100644 --- a/forum/utils.py +++ b/forum/utils.py @@ -40,3 +40,8 @@ def handle_proxy_requests(request: HttpRequest, suffix: str, method: str) -> Res headers=request_headers, timeout=5.0, ) + + +def str_to_bool(value: str) -> bool: + """Convert str to bool.""" + return value.lower() in ("true", "1") diff --git a/forum/views/comments.py b/forum/views/comments.py new file mode 100644 index 00000000..d470f575 --- /dev/null +++ b/forum/views/comments.py @@ -0,0 +1,237 @@ +"""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 +from rest_framework.response import Response +from rest_framework.serializers import ValidationError +from rest_framework.views import APIView + +from forum.models import Comment +from forum.serializers.comment import UserCommentSerializer +from forum.utils import str_to_bool + + +class CommentsAPIView(APIView): + """ + API View to handle GET, POST, PUT, and DELETE requests for comments. + """ + + permission_classes = (AllowAny,) + + def _validate_comment(self, comment_id: str) -> dict[str, Any]: + """ + Validates the comment if it exists or not. + + Parameters: + comment_id: The ID of the comment. + Response: + comment object for the given comment_id. + """ + comment = Comment().get(comment_id) + if not comment: + raise ObjectDoesNotExist + return comment + + def get(self, request: Request, comment_id: str) -> Response: + """ + Retrieves a parent comment. + For chile comments, below API is called that return all child comments in children field + url: http://localhost:8000/forum/api/v2/threads/66ab94950dead7001deb947a + + Parameters: + request (Request): The incoming request. + comment_id: The ID of the comment. + Body: + Empty. + Response: + The details of the comment for the given comment_id. + """ + try: + comment = self._validate_comment(comment_id) + except ObjectDoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + data = self._prepare_response( + comment, + exclude_fields=["sk"], + ) + return Response(data, status=status.HTTP_200_OK) + + def post(self, request: Request, comment_id: str) -> Response: + """ + Creates a new child comment. + For parent comment below API is called. + url: http://localhost:8000/forum/api/v2/threads/66ab94950dead7001deb947a/comments + + Parameters: + request (Request): The incoming request. + comment_id: The ID of the parent comment for creating it's child comment. + Body: + 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. + """ + data = request.data + fields_to_validate = ["body", "course_id", "user_id"] + for field in fields_to_validate: + if field not in data: + return Response( + f"{field} is missing.", + status=status.HTTP_400_BAD_REQUEST, + ) + + new_comment_id = Comment().insert( + body=data["body"], + course_id=data["course_id"], + anonymous=data.get("anonymous", False), + anonymous_to_peers=data.get("anonymous_to_peers", False), + author_id=data["user_id"], + parent_id=comment_id, + depth=1, + ) + comment = Comment().get(new_comment_id) + try: + if comment: + data = self._prepare_response( + comment, + exclude_fields=["endorsement", "sk"], + ) + except ValidationError as error: + return Response( + error, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(data, status=status.HTTP_200_OK) + + def put(self, request: Request, comment_id: str) -> Response: + """ + Updates an existing child/parent comment. + + Parameters: + request (Request): The incoming request. + comment_id: The ID of the comment to be edited. + Body: + fields to be updated. + Response: + The details of the comment that is updated. + """ + try: + comment = self._validate_comment(comment_id) + except ObjectDoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + data = request.POST.dict() + update_comment_data: dict[str, Any] = self._get_update_comment_data(data) + 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: + data = self._prepare_response( + updated_comment, + exclude_fields=( + ["endorsement", "sk"] + if updated_comment.get("parent_id") + else ["sk"] + ), + ) + except ValidationError as error: + return Response( + error, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(data, status=status.HTTP_200_OK) + + def delete(self, request: Request, comment_id: str) -> Response: + """ + Deletes a comment. + + Parameters: + request (Request): The incoming request. + comment_id: The ID of the comment to be deleted. + Body: + Empty. + Response: + The details of the comment that is deleted. + """ + try: + comment = self._validate_comment(comment_id) + except ObjectDoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + data = self._prepare_response( + comment, + exclude_fields=["endorsement", "sk"], + ) + Comment().delete(comment_id) + + return Response(data, status=status.HTTP_200_OK) + + def _prepare_response( + self, 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(), + } + serializer = UserCommentSerializer( + data=comment_data, + exclude_fields=exclude_fields, + ) + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + return serializer.data + + 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} diff --git a/tests/test_models/test_comments.py b/tests/test_models/test_comments.py index 48ba9b7d..aab39378 100644 --- a/tests/test_models/test_comments.py +++ b/tests/test_models/test_comments.py @@ -43,13 +43,25 @@ def test_list() -> None: author_username = "edly" Comment().insert( - "

Comment 1

", course_id, thread_id, author_id, author_username + "

Comment 1

", + course_id, + author_id, + comment_thread_id=thread_id, + author_username=author_username, ) Comment().insert( - "

Comment 2

", course_id, thread_id, author_id, author_username + "

Comment 2

", + course_id, + author_id, + comment_thread_id=thread_id, + author_username=author_username, ) Comment().insert( - "

Comment 3

", course_id, thread_id, author_id, author_username + "

Comment 3

", + course_id, + author_id, + comment_thread_id=thread_id, + author_username=author_username, ) comments_list = Comment().list() diff --git a/tests/test_views/test_flags.py b/tests/test_views/test_flags.py index 7de3adbd..d7cd8913 100644 --- a/tests/test_views/test_flags.py +++ b/tests/test_views/test_flags.py @@ -56,9 +56,9 @@ def test_comment_flag_api(api_client: APIClient) -> None: comment_id = Comment().insert( "

Comment 1

", course_id, - comment_thread_id, author_id, - author_username, + comment_thread_id=comment_thread_id, + author_username=author_username, abuse_flaggers=[], historical_abuse_flaggers=[], )