From 22377a5eb4bac31c14f8eeb3c7155c85052a74b2 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 3 Aug 2024 11:34:37 +0100 Subject: [PATCH 1/6] Combine schema for like and dislike, and query dislike after creation --- api/v1/routes/blog.py | 8 +++++--- api/v1/schemas/blog.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index e67209b4a..af1792419 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -14,7 +14,7 @@ BlogPostResponse, BlogRequest, BlogUpdateResponseModel, - BlogDislikeResponse, + BlogLikeDislikeResponse, ) from api.v1.services.blog import BlogService from api.v1.services.user import user_service @@ -105,7 +105,7 @@ async def update_blog( ) -@blog.put("/{blog_id}/dislike", response_model=BlogDislikeResponse) +@blog.put("/{blog_id}/dislike", response_model=BlogLikeDislikeResponse) def dislike_blog_post( blog_id: str, db: Session = Depends(get_db), @@ -130,8 +130,10 @@ def dislike_blog_post( ) # UPDATE disikes - new_dislike = blog_service.create_blog_dislike(db, blog_p.id, current_user.id) + blog_service.create_blog_dislike(db, blog_p.id, current_user.id) + # CONFIRM new dislike + new_dislike = blog_service.fetch_blog_dislike(blog_p.id, current_user.id) if not isinstance(new_dislike, BlogDislike): raise HTTPException( detail="Unable to record dislike.", status_code=status.HTTP_400_BAD_REQUEST diff --git a/api/v1/schemas/blog.py b/api/v1/schemas/blog.py index 82f4ab199..1f8b6471f 100644 --- a/api/v1/schemas/blog.py +++ b/api/v1/schemas/blog.py @@ -55,7 +55,7 @@ class Config: from_attributes = True -class BlogDislikeCreate(BaseModel): +class BlogLikeDislikeCreate(BaseModel): id: str blog_id: str user_id: str @@ -63,7 +63,7 @@ class BlogDislikeCreate(BaseModel): created_at: datetime -class BlogDislikeResponse(BaseModel): +class BlogLikeDislikeResponse(BaseModel): status_code: str message: str - data: BlogDislikeCreate + data: BlogLikeDislikeCreate From 506233d6af8a66e5dbb4c015697a19cda8a43abf Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 3 Aug 2024 12:08:55 +0100 Subject: [PATCH 2/6] Update /test_dislike_blog_post.py file --- tests/v1/blog/test_dislike_blog_post.py | 44 +++++++++++++++++-------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/tests/v1/blog/test_dislike_blog_post.py b/tests/v1/blog/test_dislike_blog_post.py index 856aea7e2..6350ce923 100644 --- a/tests/v1/blog/test_dislike_blog_post.py +++ b/tests/v1/blog/test_dislike_blog_post.py @@ -3,6 +3,7 @@ from uuid_extensions import uuid7 from sqlalchemy.orm import Session from api.db.database import get_db +from datetime import datetime, timezone from fastapi.testclient import TestClient from unittest.mock import patch, MagicMock from api.v1.services.user import user_service @@ -26,7 +27,7 @@ def mock_user_service(): @pytest.fixture def mock_blog_service(): - with patch("api.v1.services.user.BlogService", autospec=True) as blog_service_mock: + with patch("api.v1.services.blog.BlogService", autospec=True) as blog_service_mock: yield blog_service_mock @@ -54,12 +55,15 @@ def test_blog(test_user): @pytest.fixture() def test_blog_dislike(test_user, test_blog): return BlogDislike( + id=str(uuid7()), user_id=test_user.id, blog_id=test_blog.id, + ip_address="192.168.1.0", + created_at=datetime.now(tz=timezone.utc) ) @pytest.fixture -def access_token_user1(test_user): +def access_token_user(test_user): return user_service.create_access_token(user_id=test_user.id) def make_request(blog_id, token): @@ -68,20 +72,32 @@ def make_request(blog_id, token): headers={"Authorization": f"Bearer {token}"} ) -# Test for successful dislike + def test_successful_dislike( mock_db_session, test_user, test_blog, - access_token_user1, + test_blog_dislike, + access_token_user ): - mock_user_service.get_current_user = test_user - mock_db_session.query.return_value.filter.return_value.first.return_value = test_blog - mock_db_session.query.return_value.filter_by.return_value.first.return_value = None + # mock current-user AND blog-post + mock_db_session.query().filter().first.side_effect = [test_user, test_blog] + + # mock existing-blog-dislike AND new-blog-dislike + mock_db_session.query().filter_by().first.side_effect = [None, test_blog_dislike] - resp = make_request(test_blog.id, access_token_user1) + resp = make_request(test_blog.id, access_token_user) + resp_d = resp.json() assert resp.status_code == 200 - assert resp.json()['message'] == "Dislike recorded successfully." + assert resp_d['success'] == True + assert resp_d['message'] == "Dislike recorded successfully." + + dislike_data = resp_d['data'] + assert dislike_data['id'] == test_blog_dislike.id + assert dislike_data['blog_id'] == test_blog.id + assert dislike_data['user_id'] == test_user.id + assert dislike_data['ip_address'] == test_blog_dislike.ip_address + assert datetime.fromisoformat(dislike_data['created_at']) == test_blog_dislike.created_at # Test for double dislike @@ -90,14 +106,14 @@ def test_double_dislike( test_user, test_blog, test_blog_dislike, - access_token_user1, + access_token_user, ): mock_user_service.get_current_user = test_user mock_db_session.query.return_value.filter.return_value.first.return_value = test_blog mock_db_session.query.return_value.filter_by.return_value.first.return_value = test_blog_dislike ### TEST ATTEMPT FOR MULTIPLE DISLIKING... ### - resp = make_request(test_blog.id, access_token_user1) + resp = make_request(test_blog.id, access_token_user) assert resp.status_code == 403 assert resp.json()['message'] == "You have already disliked this blog post" @@ -105,14 +121,14 @@ def test_double_dislike( def test_wrong_blog_id( mock_db_session, test_user, - access_token_user1, + access_token_user, ): mock_user_service.get_current_user = test_user mock_blog_service.fetch = None ### TEST REQUEST WITH WRONG blog_id ### ### using random uuid instead of blog1.id ### - resp = make_request(str(uuid7()), access_token_user1) + resp = make_request(str(uuid7()), access_token_user) assert resp.status_code == 404 assert resp.json()['message'] == "Post not found" @@ -127,4 +143,4 @@ def test_wrong_auth_token( ### TEST ATTEMPT WITH INVALID AUTH... ### resp = make_request(test_blog.id, None) assert resp.status_code == 401 - assert resp.json()['message'] == 'Could not validate credentials' + assert resp.json()['message'] == 'Could not validate credentials' \ No newline at end of file From 8b4d1fa6fe961c384ddaaa9534fb8327ce0c6a1a Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 3 Aug 2024 13:08:23 +0100 Subject: [PATCH 3/6] Add client_helpers.py file AND use it to set ip_address for dislike --- api/utils/client_helpers.py | 8 ++++++++ api/v1/routes/blog.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 api/utils/client_helpers.py diff --git a/api/utils/client_helpers.py b/api/utils/client_helpers.py new file mode 100644 index 000000000..e3b0968a1 --- /dev/null +++ b/api/utils/client_helpers.py @@ -0,0 +1,8 @@ +# All helper functions relating to user clients + + +def get_ip_address(request): + client_ip = request.headers.get("X-Forwarded-For") + if client_ip is None or client_ip == "": + client_ip = request.client.host + return client_ip \ No newline at end of file diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index af1792419..b66801626 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -1,6 +1,9 @@ from typing import List, Annotated -from fastapi import APIRouter, Depends, HTTPException, status, HTTPException, Response +from fastapi import ( + APIRouter, Depends, HTTPException, status, + HTTPException, Response, Request +) from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session @@ -21,6 +24,7 @@ from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service from api.v1.services.comment import CommentService +from api.utils.client_helpers import get_ip_address blog = APIRouter(prefix="/blogs", tags=["Blog"]) @@ -108,6 +112,7 @@ async def update_blog( @blog.put("/{blog_id}/dislike", response_model=BlogLikeDislikeResponse) def dislike_blog_post( blog_id: str, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), ): @@ -130,7 +135,8 @@ def dislike_blog_post( ) # UPDATE disikes - blog_service.create_blog_dislike(db, blog_p.id, current_user.id) + blog_service.create_blog_dislike( + db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) # CONFIRM new dislike new_dislike = blog_service.fetch_blog_dislike(blog_p.id, current_user.id) From b3bb5c0539cddb47cab648d71d44999af45c488a Mon Sep 17 00:00:00 2001 From: Chime Date: Mon, 5 Aug 2024 15:31:11 +0100 Subject: [PATCH 4/6] Change request method for routes.blog.dislike_blog_post from PUT to POST --- api/v1/routes/blog.py | 2 +- tests/v1/blog/test_dislike_blog_post.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index b66801626..8dc81cdad 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -109,7 +109,7 @@ async def update_blog( ) -@blog.put("/{blog_id}/dislike", response_model=BlogLikeDislikeResponse) +@blog.post("/{blog_id}/dislike", response_model=BlogLikeDislikeResponse) def dislike_blog_post( blog_id: str, request: Request, diff --git a/tests/v1/blog/test_dislike_blog_post.py b/tests/v1/blog/test_dislike_blog_post.py index 6350ce923..7f335bf28 100644 --- a/tests/v1/blog/test_dislike_blog_post.py +++ b/tests/v1/blog/test_dislike_blog_post.py @@ -67,7 +67,7 @@ def access_token_user(test_user): return user_service.create_access_token(user_id=test_user.id) def make_request(blog_id, token): - return client.put( + return client.post( f"/api/v1/blogs/{blog_id}/dislike", headers={"Authorization": f"Bearer {token}"} ) From 509ab5241580c284fcecda2a7bd16725d3fd9619 Mon Sep 17 00:00:00 2001 From: Chime Date: Thu, 8 Aug 2024 09:44:42 +0100 Subject: [PATCH 5/6] Add objects_count to data returned from blog like/dislike requests --- api/v1/routes/blog.py | 38 ++++++++++++++++++++++--- api/v1/schemas/blog.py | 7 ++++- tests/v1/blog/test_dislike_blog_post.py | 6 +++- tests/v1/blog/test_like_blog_post.py | 6 +++- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 1c25e69f2..094fb6a0e 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -115,7 +115,18 @@ def like_blog_post( db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), ): + """Endpoint to add `like` to a blog post. + args: + blog_id: `str` The ID of the blog post. + request: `default` Request. + db: `default` Session. + + return: + In the `data` returned, `"object"` represents details of the + BlogLike obj and the `"objects_count"` represents the number + of BlogLike for the blog post + """ blog_service = BlogService(db) # GET blog post @@ -141,14 +152,18 @@ def like_blog_post( new_like = blog_service.fetch_blog_like(blog_p.id, current_user.id) if not isinstance(new_like, BlogLike): raise HTTPException( - detail="Unable to record like.", status_code=status.HTTP_400_BAD_REQUEST + detail="Unable to record like.", + status_code=status.HTTP_400_BAD_REQUEST ) # Return success response return success_response( status_code=status.HTTP_200_OK, message="Like recorded successfully.", - data=new_like.to_dict(), + data={ + 'object': new_like.to_dict(), + 'objects_count': blog_service.num_of_likes(blog_id) + }, ) @@ -159,7 +174,18 @@ def dislike_blog_post( db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), ): + """Endpoint to add `dislike` to a blog post. + args: + blog_id: `str` The ID of the blog post. + request: `default` Request. + db: `default` Session. + + return: + In the `data` returned, `"object"` represents details of the + BlogDislike obj and the `"objects_count"` represents the number + of BlogDislike for the blog post + """ blog_service = BlogService(db) # GET blog post @@ -185,14 +211,18 @@ def dislike_blog_post( new_dislike = blog_service.fetch_blog_dislike(blog_p.id, current_user.id) if not isinstance(new_dislike, BlogDislike): raise HTTPException( - detail="Unable to record dislike.", status_code=status.HTTP_400_BAD_REQUEST + detail="Unable to record dislike.", + status_code=status.HTTP_400_BAD_REQUEST ) # Return success response return success_response( status_code=status.HTTP_200_OK, message="Dislike recorded successfully.", - data=new_dislike.to_dict(), + data={ + 'object': new_dislike.to_dict(), + 'objects_count': blog_service.num_of_dislikes(blog_id) + }, ) diff --git a/api/v1/schemas/blog.py b/api/v1/schemas/blog.py index 1f8b6471f..18361b407 100644 --- a/api/v1/schemas/blog.py +++ b/api/v1/schemas/blog.py @@ -63,7 +63,12 @@ class BlogLikeDislikeCreate(BaseModel): created_at: datetime +class BlogLikeDislikeCreateData(BaseModel): + object: BlogLikeDislikeCreate + objects_count: int # number of likes/dislikes + + class BlogLikeDislikeResponse(BaseModel): status_code: str message: str - data: BlogLikeDislikeCreate + data: BlogLikeDislikeCreateData diff --git a/tests/v1/blog/test_dislike_blog_post.py b/tests/v1/blog/test_dislike_blog_post.py index 7f335bf28..0e40f379f 100644 --- a/tests/v1/blog/test_dislike_blog_post.py +++ b/tests/v1/blog/test_dislike_blog_post.py @@ -86,18 +86,22 @@ def test_successful_dislike( # mock existing-blog-dislike AND new-blog-dislike mock_db_session.query().filter_by().first.side_effect = [None, test_blog_dislike] + # mock dislike-count + mock_db_session.query().filter_by().count.return_value = 1 + resp = make_request(test_blog.id, access_token_user) resp_d = resp.json() assert resp.status_code == 200 assert resp_d['success'] == True assert resp_d['message'] == "Dislike recorded successfully." - dislike_data = resp_d['data'] + dislike_data = resp_d['data']['object'] assert dislike_data['id'] == test_blog_dislike.id assert dislike_data['blog_id'] == test_blog.id assert dislike_data['user_id'] == test_user.id assert dislike_data['ip_address'] == test_blog_dislike.ip_address assert datetime.fromisoformat(dislike_data['created_at']) == test_blog_dislike.created_at + assert resp_d['data']['objects_count'] == 1 # Test for double dislike diff --git a/tests/v1/blog/test_like_blog_post.py b/tests/v1/blog/test_like_blog_post.py index e899060df..866e63b36 100644 --- a/tests/v1/blog/test_like_blog_post.py +++ b/tests/v1/blog/test_like_blog_post.py @@ -87,6 +87,9 @@ def test_successful_like( # mock existing-blog-like AND new-blog-like mock_db_session.query().filter_by().first.side_effect = [None, test_blog_like] + # mock like-count + mock_db_session.query().filter_by().count.return_value = 1 + resp = make_request(test_blog.id, access_token_user) resp_d = resp.json() print(resp_d) @@ -94,12 +97,13 @@ def test_successful_like( assert resp_d['success'] == True assert resp_d['message'] == "Like recorded successfully." - like_data = resp_d['data'] + like_data = resp_d['data']['object'] assert like_data['id'] == test_blog_like.id assert like_data['blog_id'] == test_blog.id assert like_data['user_id'] == test_user.id assert like_data['ip_address'] == test_blog_like.ip_address assert datetime.fromisoformat(like_data['created_at']) == test_blog_like.created_at + assert resp_d['data']['objects_count'] == 1 # Test for double like From 20cdb6584b1b0f3c1ca952ceba950155c772621e Mon Sep 17 00:00:00 2001 From: Chime Date: Thu, 22 Aug 2024 22:14:54 +0100 Subject: [PATCH 6/6] Change dislike blog endpoint method from PUT to POST. Add objects_count to data returned from blog like and dislike creation. Add ip_address to blog dislike during creation. Update schemas, docs and tests to cover changes --- api/v1/routes/blog.py | 62 ++++++------------------- api/v1/services/blog.py | 18 ++++++- tests/v1/blog/test_dislike_blog_post.py | 9 +++- tests/v1/blog/test_like_blog_post.py | 12 +++-- 4 files changed, 47 insertions(+), 54 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 2625c0d5c..8a080ee84 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -131,32 +131,15 @@ def like_blog_post( """ blog_service = BlogService(db) - # GET blog post + # get blog post blog_p = blog_service.fetch(blog_id) - if not isinstance(blog_p, Blog): - raise HTTPException( - detail="Post not found", status_code=status.HTTP_404_NOT_FOUND - ) - - # CONFIRM current user has NOT liked before - existing_like = blog_service.fetch_blog_like(blog_p.id, current_user.id) - if isinstance(existing_like, BlogLike): - raise HTTPException( - detail="You have already liked this blog post", - status_code=status.HTTP_403_FORBIDDEN, - ) - - # UPDATE likes - blog_service.create_blog_like( + + # confirm current user has NOT liked before + blog_service.check_user_already_liked_blog(blog_p, current_user) + + # update likes + new_like = blog_service.create_blog_like( db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) - - # CONFIRM new like - new_like = blog_service.fetch_blog_like(blog_p.id, current_user.id) - if not isinstance(new_like, BlogLike): - raise HTTPException( - detail="Unable to record like.", - status_code=status.HTTP_400_BAD_REQUEST - ) # Return success response return success_response( @@ -190,32 +173,15 @@ def dislike_blog_post( """ blog_service = BlogService(db) - # GET blog post + # get blog post blog_p = blog_service.fetch(blog_id) - if not isinstance(blog_p, Blog): - raise HTTPException( - detail="Post not found", status_code=status.HTTP_404_NOT_FOUND - ) - - # CONFIRM current user has NOT disliked before - existing_dislike = blog_service.fetch_blog_dislike(blog_p.id, current_user.id) - if isinstance(existing_dislike, BlogDislike): - raise HTTPException( - detail="You have already disliked this blog post", - status_code=status.HTTP_403_FORBIDDEN, - ) - - # UPDATE disikes - blog_service.create_blog_dislike( - db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) - # CONFIRM new dislike - new_dislike = blog_service.fetch_blog_dislike(blog_p.id, current_user.id) - if not isinstance(new_dislike, BlogDislike): - raise HTTPException( - detail="Unable to record dislike.", - status_code=status.HTTP_400_BAD_REQUEST - ) + # confirm current user has NOT disliked before + blog_service.check_user_already_disliked_blog(blog_p, current_user) + + # update disikes + new_dislike = blog_service.create_blog_dislike( + db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) # Return success response return success_response( diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index b65473631..caa4365f3 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -1,6 +1,6 @@ from typing import Optional -from fastapi import HTTPException +from fastapi import HTTPException, status from sqlalchemy.orm import Session from api.core.base.services import Service @@ -117,6 +117,22 @@ def fetch_blog_dislike(self, blog_id: str, user_id: str): .first() ) return blog_dislike + + def check_user_already_liked_blog(self, blog: Blog, user: Blog): + existing_like = self.fetch_blog_like(blog.id, user.id) + if isinstance(existing_like, BlogLike): + raise HTTPException( + detail="You have already liked this blog post", + status_code=status.HTTP_403_FORBIDDEN, + ) + + def check_user_already_disliked_blog(self, blog: Blog, user: Blog): + existing_dislike = self.fetch_blog_dislike(blog.id, user.id) + if isinstance(existing_dislike, BlogDislike): + raise HTTPException( + detail="You have already disliked this blog post", + status_code=status.HTTP_403_FORBIDDEN, + ) def num_of_likes(self, blog_id: str) -> int: """Get the number of likes a blog post has""" diff --git a/tests/v1/blog/test_dislike_blog_post.py b/tests/v1/blog/test_dislike_blog_post.py index 0e40f379f..82a5e2dd4 100644 --- a/tests/v1/blog/test_dislike_blog_post.py +++ b/tests/v1/blog/test_dislike_blog_post.py @@ -73,7 +73,9 @@ def make_request(blog_id, token): ) +@patch("api.v1.services.blog.BlogService.create_blog_dislike") def test_successful_dislike( + mock_create_blog_dislike, mock_db_session, test_user, test_blog, @@ -84,7 +86,10 @@ def test_successful_dislike( mock_db_session.query().filter().first.side_effect = [test_user, test_blog] # mock existing-blog-dislike AND new-blog-dislike - mock_db_session.query().filter_by().first.side_effect = [None, test_blog_dislike] + mock_db_session.query().filter_by().first.side_effect = None + + # mock created-blog-dislike + mock_create_blog_dislike.return_value = test_blog_dislike # mock dislike-count mock_db_session.query().filter_by().count.return_value = 1 @@ -128,7 +133,7 @@ def test_wrong_blog_id( access_token_user, ): mock_user_service.get_current_user = test_user - mock_blog_service.fetch = None + mock_db_session.query().filter().first.return_value = None ### TEST REQUEST WITH WRONG blog_id ### ### using random uuid instead of blog1.id ### diff --git a/tests/v1/blog/test_like_blog_post.py b/tests/v1/blog/test_like_blog_post.py index 866e63b36..9544592cf 100644 --- a/tests/v1/blog/test_like_blog_post.py +++ b/tests/v1/blog/test_like_blog_post.py @@ -74,7 +74,9 @@ def make_request(blog_id, token): ) # Test for successful like +@patch("api.v1.services.blog.BlogService.create_blog_like") def test_successful_like( + mock_create_blog_like, mock_db_session, test_user, test_blog, @@ -84,8 +86,11 @@ def test_successful_like( # mock current-user AND blog-post mock_db_session.query().filter().first.side_effect = [test_user, test_blog] - # mock existing-blog-like AND new-blog-like - mock_db_session.query().filter_by().first.side_effect = [None, test_blog_like] + # mock existing-blog-like + mock_db_session.query().filter_by().first.return_value = None + + # mock created-blog-like + mock_create_blog_like.return_value = test_blog_like # mock like-count mock_db_session.query().filter_by().count.return_value = 1 @@ -125,12 +130,13 @@ def test_double_like( # Test for wrong blog id def test_wrong_blog_id( + # mock_fetch_blog, mock_db_session, test_user, access_token_user, ): mock_user_service.get_current_user = test_user - mock_blog_service.fetch = None + mock_db_session.query().filter().first.return_value = None ### TEST REQUEST WITH WRONG blog_id ### ### using random uuid instead of blog1.id ###