From 6b3075cda2be95f3f806e62a341d49b8243ccbed Mon Sep 17 00:00:00 2001 From: theijhay Date: Sat, 24 Aug 2024 00:49:42 +0100 Subject: [PATCH 01/30] fix duplicate timezones --- api/v1/routes/regions.py | 15 ++++++++++++++- api/v1/services/regions.py | 15 ++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/api/v1/routes/regions.py b/api/v1/routes/regions.py index 30d206c1d..97f0c206d 100644 --- a/api/v1/routes/regions.py +++ b/api/v1/routes/regions.py @@ -60,4 +60,17 @@ def update_region(region_id: str, region: RegionUpdate, db: Session = Depends(ge @regions.delete("/{region_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_region(region_id: str, db: Session = Depends(get_db)): region = region_service.delete(db, region_id) - return \ No newline at end of file + return + + +@regions.get( + '/', response_model=List[str]) +def get_unique_timezones(db: Session = Depends(get_db)): + + '''Get unique time zones''' + timezones = region_service.fetch_unique_timezones(db) + return success_response( + status_code=200, + message='Timezones retrieved successfully', + data=jsonable_encoder(timezones) + ) diff --git a/api/v1/services/regions.py b/api/v1/services/regions.py index c81e2fae1..57f7b91a0 100644 --- a/api/v1/services/regions.py +++ b/api/v1/services/regions.py @@ -1,11 +1,11 @@ -from typing import Any, Optional +from typing import Any, Optional, List from sqlalchemy.orm import Session from api.core.base.services import Service from api.v1.models.regions import Region from api.v1.schemas.regions import RegionUpdate, RegionCreate from api.utils.db_validators import check_model_existence - - +from sqlalchemy import distinct +from fastapi import HTTPException class RegionService(Service): """Region Services""" @@ -64,6 +64,15 @@ def delete(self, db: Session, region_id: str): region = self.fetch(db=db, region_id=region_id) db.delete(region) db.commit() + + + def fetch_unique_timezones(self, db: Session): + '''Fetch unique time zones without duplicates''' + timezones = db.query(distinct(Region.timezone)).filter(Region.timezone.isnot(None)).all() + """Extract unique time zones as a list""" + unique_timezones = sorted([tz[0] for tz in timezones if tz[0]]) + """Return unique timezones""" + return unique_timezones region_service = RegionService() From 75aa1443aa451b41715d34df28495d1a23b5f783 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 11:33:16 +0100 Subject: [PATCH 02/30] Add code for delete blog like endpoint --- api/v1/routes/blog.py | 30 ++++++++++++++- api/v1/services/blog.py | 81 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 8a080ee84..71564be18 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -20,7 +20,7 @@ CommentRequest, CommentUpdateResponseModel ) -from api.v1.services.blog import BlogService +from api.v1.services.blog import BlogService, BlogLikeService, BlogDislikeService from api.v1.services.user import user_service from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service @@ -118,6 +118,7 @@ def like_blog_post( current_user: User = Depends(user_service.get_current_user), ): """Endpoint to add `like` to a blog post. + Existing `dislike` by the `current_user` is automatically deleted. args: blog_id: `str` The ID of the blog post. @@ -137,6 +138,9 @@ def like_blog_post( # confirm current user has NOT liked before blog_service.check_user_already_liked_blog(blog_p, current_user) + # check for BlogDislike by current user and delete it + blog_service.delete_opposite_blog_like_or_dislike(blog_p, current_user, "like") + # update likes new_like = blog_service.create_blog_like( db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) @@ -160,6 +164,7 @@ def dislike_blog_post( current_user: User = Depends(user_service.get_current_user), ): """Endpoint to add `dislike` to a blog post. + Existing `like` by the `current_user` is automatically deleted. args: blog_id: `str` The ID of the blog post. @@ -179,6 +184,9 @@ def dislike_blog_post( # confirm current user has NOT disliked before blog_service.check_user_already_disliked_blog(blog_p, current_user) + # check for BlogLike by current user and delete it + blog_service.delete_opposite_blog_like_or_dislike(blog_p, current_user, "dislike") + # update disikes new_dislike = blog_service.create_blog_dislike( db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) @@ -299,3 +307,23 @@ async def update_blog_comment( status_code=200, data=jsonable_encoder(updated_blog_comment) ) + + +@blog.delete("/likes/{blog_like_id}", + status_code=status.HTTP_204_NO_CONTENT) +def delete_blog_like( + blog_like_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user), +): + """Endpoint to delete `BlogLike` + + args: + blog_like_id: `str` The ID of the BlogLike object. + request: `default` Request. + db: `default` Session. + """ + blog_like_service = BlogLikeService(db) + + # delete blog like + blog_like_service.delete(blog_like_id, current_user.id) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index caa4365f3..4478230c3 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -118,7 +118,7 @@ def fetch_blog_dislike(self, blog_id: str, user_id: str): ) return blog_dislike - def check_user_already_liked_blog(self, blog: Blog, user: Blog): + def check_user_already_liked_blog(self, blog: Blog, user: User): existing_like = self.fetch_blog_like(blog.id, user.id) if isinstance(existing_like, BlogLike): raise HTTPException( @@ -126,13 +126,40 @@ def check_user_already_liked_blog(self, blog: Blog, user: Blog): status_code=status.HTTP_403_FORBIDDEN, ) - def check_user_already_disliked_blog(self, blog: Blog, user: Blog): + def check_user_already_disliked_blog(self, blog: Blog, user: User): 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 delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: str): + """ + This method checks if there's a BlogLike by `user` on `blog` when a BlogDislike + is being created and deletes the BlogLike. The same for BlogLike creation. \n + + :param blog: `Blog` The blog being liked or disliked + :param user: `User` The user liking or disliking the blog + :param creating: `str` The operation being performed by the user. One of "like", "dislike" + """ + if creating == "like": + existing_dislike = self.fetch_blog_dislike(blog.id, user.id) + if existing_dislike: + # delete, but do not commit yet. Allow everything + # to be commited when operation like created + self.db.delete(existing_dislike) + if creating == "dislike": + existing_like = self.fetch_blog_like(blog.id, user.id) + if existing_like: + # delete, but do not commit yet. Allow everything + # to be commited when operation dislike created + self.db.delete(existing_like) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid `creating` value for blog like/dislike" + ) def num_of_likes(self, blog_id: str) -> int: """Get the number of likes a blog post has""" @@ -211,3 +238,53 @@ def update_blog_comment( ) return comment + + +class BlogLikeService: + """BlogLike service functionality""" + + def __init__(self, db: Session): + self.db = db + + def fetch(self, blog_like_id: str): + """Fetch a blog like by its ID""" + return check_model_existence(self.db, BlogLike, blog_like_id) + + def delete(self, blog_like_id: str, user_id: str): + """Delete blog like""" + blog_like = self.fetch(blog_like_id) + + # check that current user owns the blog like + if blog_like.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Insufficient permission" + ) + + self.db.delete(blog_like) + self.db.commit() + + +class BlogDislikeService: + """BlogDislike service functionality""" + + def __init__(self, db: Session): + self.db = db + + def fetch(self, blog_dislike_id: str): + """Fetch a blog dislike by its ID""" + return check_model_existence(self.db, BlogLike, blog_dislike_id) + + def delete(self, blog_dislike_id: str, user_id: str): + """Delete blog dislike""" + blog_dislike = self.fetch(blog_dislike_id) + + # check that current user owns the blog like + if blog_dislike.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Insufficient permission" + ) + + self.db.delete(blog_dislike) + self.db.commit() From 296892d68cdfbcbe185cdb5ffa2913b3041b0c4c Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 11:44:36 +0100 Subject: [PATCH 03/30] Add code for delete blog dislike endpoint --- api/v1/routes/blog.py | 16 ++++++++-------- api/v1/services/blog.py | 25 ------------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 71564be18..ee38db619 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -309,21 +309,21 @@ async def update_blog_comment( ) -@blog.delete("/likes/{blog_like_id}", +@blog.delete("/dislikes/{blog_dislike_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_blog_like( - blog_like_id: str, +def delete_blog_dislike( + blog_dislike_id: str, db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), ): - """Endpoint to delete `BlogLike` + """Endpoint to delete `BlogDislike` args: - blog_like_id: `str` The ID of the BlogLike object. + blog_dislike_id: `str` The ID of the BlogDislike object. request: `default` Request. db: `default` Session. """ - blog_like_service = BlogLikeService(db) + blog_dislike_service = BlogDislikeService(db) - # delete blog like - blog_like_service.delete(blog_like_id, current_user.id) + # delete blog dislike + blog_dislike_service.delete(blog_dislike_id, current_user.id) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 4478230c3..c2dd86713 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -240,31 +240,6 @@ def update_blog_comment( return comment -class BlogLikeService: - """BlogLike service functionality""" - - def __init__(self, db: Session): - self.db = db - - def fetch(self, blog_like_id: str): - """Fetch a blog like by its ID""" - return check_model_existence(self.db, BlogLike, blog_like_id) - - def delete(self, blog_like_id: str, user_id: str): - """Delete blog like""" - blog_like = self.fetch(blog_like_id) - - # check that current user owns the blog like - if blog_like.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permission" - ) - - self.db.delete(blog_like) - self.db.commit() - - class BlogDislikeService: """BlogDislike service functionality""" From c12f3c1c65a5e4534fc900c07ddceae0f31b57b8 Mon Sep 17 00:00:00 2001 From: theijhay Date: Sat, 24 Aug 2024 12:38:25 +0100 Subject: [PATCH 04/30] made changes --- api/v1/routes/regions.py | 49 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/api/v1/routes/regions.py b/api/v1/routes/regions.py index 97f0c206d..cd4c08167 100644 --- a/api/v1/routes/regions.py +++ b/api/v1/routes/regions.py @@ -28,15 +28,33 @@ def create_region(region: RegionCreate, db: Session = Depends(get_db), ) @regions.get("", response_model=List[RegionOut]) -def get_regions(db: Session = Depends(get_db)): - """Get All Regions""" - regions = region_service.fetch_all(db) - - return success_response( - status_code=200, - message='Regions retrieved successfully', - data=jsonable_encoder(regions) - ) +def get_regions_or_timezones( + db: Session = Depends(get_db), + timezones: Optional[bool] = Query(False, description="Set to true to fetch unique time zones") +): + """ + Fetch all regions or unique time zones based on the timezones query parameter. + """ + if timezones: + unique_timezones = region_service.fetch_unique_timezones(db) + if not unique_timezones: + raise HTTPException( + status_code=404, + detail="No time zones found." + ) + return success_response( + status_code=200, + message='Time zones retrieved successfully', + data=unique_timezones + ) + else: + regions = region_service.fetch_all(db) + return success_response( + status_code=200, + message='Regions retrieved successfully', + data=regions + ) + @regions.get("/{region_id}", response_model=RegionOut) def get_region_by_user(region_id: str, db: Session = Depends(get_db)): @@ -61,16 +79,3 @@ def update_region(region_id: str, region: RegionUpdate, db: Session = Depends(ge def delete_region(region_id: str, db: Session = Depends(get_db)): region = region_service.delete(db, region_id) return - - -@regions.get( - '/', response_model=List[str]) -def get_unique_timezones(db: Session = Depends(get_db)): - - '''Get unique time zones''' - timezones = region_service.fetch_unique_timezones(db) - return success_response( - status_code=200, - message='Timezones retrieved successfully', - data=jsonable_encoder(timezones) - ) From 9f24c4e950f0e72ab0bfb77e3e2e7e376a8dab71 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 13:14:15 +0100 Subject: [PATCH 05/30] Add test for delete blog like endpoint --- api/v1/routes/blog.py | 4 +- api/v1/services/blog.py | 31 +----- tests/v1/blog/test_delete_blog_like.py | 144 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 tests/v1/blog/test_delete_blog_like.py diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 71564be18..79e88cbf9 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -10,7 +10,7 @@ from api.utils.pagination import paginated_response from api.utils.success_response import success_response from api.v1.models.user import User -from api.v1.models.blog import Blog, BlogDislike, BlogLike +from api.v1.models.blog import Blog from api.v1.schemas.blog import ( BlogCreate, BlogPostResponse, @@ -20,7 +20,7 @@ CommentRequest, CommentUpdateResponseModel ) -from api.v1.services.blog import BlogService, BlogLikeService, BlogDislikeService +from api.v1.services.blog import BlogService, BlogLikeService from api.v1.services.user import user_service from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 4478230c3..6e6eb8423 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -147,13 +147,13 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if existing_dislike: # delete, but do not commit yet. Allow everything - # to be commited when operation like created + # to be commited after the actual like is created self.db.delete(existing_dislike) - if creating == "dislike": + elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: # delete, but do not commit yet. Allow everything - # to be commited when operation dislike created + # to be commited after the actual dislike is created self.db.delete(existing_like) else: raise HTTPException( @@ -263,28 +263,3 @@ def delete(self, blog_like_id: str, user_id: str): self.db.delete(blog_like) self.db.commit() - - -class BlogDislikeService: - """BlogDislike service functionality""" - - def __init__(self, db: Session): - self.db = db - - def fetch(self, blog_dislike_id: str): - """Fetch a blog dislike by its ID""" - return check_model_existence(self.db, BlogLike, blog_dislike_id) - - def delete(self, blog_dislike_id: str, user_id: str): - """Delete blog dislike""" - blog_dislike = self.fetch(blog_dislike_id) - - # check that current user owns the blog like - if blog_dislike.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permission" - ) - - self.db.delete(blog_dislike) - self.db.commit() diff --git a/tests/v1/blog/test_delete_blog_like.py b/tests/v1/blog/test_delete_blog_like.py new file mode 100644 index 000000000..018f1f93e --- /dev/null +++ b/tests/v1/blog/test_delete_blog_like.py @@ -0,0 +1,144 @@ +import pytest +from main import app +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.models import User, BlogDislike +from api.v1.services.user import user_service + +client = TestClient(app) + +# Mock database +@pytest.fixture +def mock_db_session(mocker): + db_session_mock = mocker.MagicMock(spec=Session) + app.dependency_overrides[get_db] = lambda: db_session_mock + return db_session_mock + + +@pytest.fixture +def mock_user_service(): + with patch("api.v1.services.user.user_service", autospec=True) as user_service_mock: + yield user_service_mock + + +@pytest.fixture +def mock_blog_service(): + with patch("api.v1.services.blog.BlogService", autospec=True) as blog_service_mock: + yield blog_service_mock + + +# Test User +@pytest.fixture +def test_user(): + return User( + id=str(uuid7()), + email="testuser@gmail.com", + password="hashedpassword", + first_name="test", + last_name="user", + is_active=True, + ) + + +# Another User +@pytest.fixture +def another_user(): + return User( + id=str(uuid7()), + email="anotheruser@gmail.com", + password="hashedpassword", + first_name="another", + last_name="user", + is_active=True, + ) + +@pytest.fixture +def test_blog_like(test_user): + return BlogDislike( + id=str(uuid7()), + user_id=test_user.id, + blog_id=str(uuid7()), + ip_address="192.168.1.0", + created_at=datetime.now(tz=timezone.utc) + ) + +@pytest.fixture +def access_token_user(test_user): + return user_service.create_access_token(user_id=test_user.id) + +@pytest.fixture +def access_token_another(another_user): + return user_service.create_access_token(user_id=another_user.id) + + +def make_request(blog_like_id, token): + return client.delete( + f"/api/v1/blogs/likes/{blog_like_id}", + headers={"Authorization": f"Bearer {token}"} + ) + + +# test for successful delete +@patch("api.v1.services.blog.BlogLikeService.fetch") +def test_successful_delete_bloglike( + mock_fetch_blog_like, + mock_db_session, + test_user, + test_blog_like, + access_token_user +): + # mock current-user AND blog-like + mock_db_session.query().filter().first.return_value = test_user + mock_fetch_blog_like.return_value = test_blog_like + + resp = make_request(test_blog_like.id, access_token_user) + assert resp.status_code == 204 + + +# Test for wrong blog like id +def test_wrong_blog_like_id( + # mock_fetch_blog_like, + mock_db_session, + test_user, + access_token_user, +): + mock_db_session.query().filter().first.return_value = test_user + mock_db_session.get.return_value = None + + ### TEST REQUEST WITH WRONG blog_like_id ### + resp = make_request(str(uuid7()), access_token_user) + assert resp.status_code == 404 + assert resp.json()['message'] == "BlogLike does not exist" + + +# Test for unauthenticated user +def test_wrong_auth_token( + mock_db_session, + test_blog_like +): + mock_user_service.get_current_user = None + + ### TEST ATTEMPT WITH INVALID AUTH ### + resp = make_request(test_blog_like.id, None) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Could not validate credentials' + + +# Test for wrong owner request +def test_wrong_owner_request( + mock_db_session, + test_blog_like, + another_user, + access_token_another +): + mock_user_service.get_current_user = another_user + mock_db_session.get.return_value = test_blog_like + + ### TEST ATTEMPT BY NON OWNER ### + resp = make_request(test_blog_like.id, access_token_another) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Insufficient permission' \ No newline at end of file From 3742223f8825934dfca05babeabe41dc7021a6be Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 13:29:08 +0100 Subject: [PATCH 06/30] Add test for delete blog dislike endpoint --- api/v1/routes/blog.py | 4 +- api/v1/services/blog.py | 2 +- tests/v1/blog/test_delete_blog_dislike.py | 142 ++++++++++++++++++++++ 3 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/v1/blog/test_delete_blog_dislike.py diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index ee38db619..a4f4632b3 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -10,7 +10,7 @@ from api.utils.pagination import paginated_response from api.utils.success_response import success_response from api.v1.models.user import User -from api.v1.models.blog import Blog, BlogDislike, BlogLike +from api.v1.models.blog import Blog from api.v1.schemas.blog import ( BlogCreate, BlogPostResponse, @@ -20,7 +20,7 @@ CommentRequest, CommentUpdateResponseModel ) -from api.v1.services.blog import BlogService, BlogLikeService, BlogDislikeService +from api.v1.services.blog import BlogService, BlogDislikeService from api.v1.services.user import user_service from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index c2dd86713..26266ba15 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -248,7 +248,7 @@ def __init__(self, db: Session): def fetch(self, blog_dislike_id: str): """Fetch a blog dislike by its ID""" - return check_model_existence(self.db, BlogLike, blog_dislike_id) + return check_model_existence(self.db, BlogDislike, blog_dislike_id) def delete(self, blog_dislike_id: str, user_id: str): """Delete blog dislike""" diff --git a/tests/v1/blog/test_delete_blog_dislike.py b/tests/v1/blog/test_delete_blog_dislike.py new file mode 100644 index 000000000..946d8a2e1 --- /dev/null +++ b/tests/v1/blog/test_delete_blog_dislike.py @@ -0,0 +1,142 @@ +import pytest +from main import app +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.models import User, BlogDislike +from api.v1.services.user import user_service + +client = TestClient(app) + +# Mock database +@pytest.fixture +def mock_db_session(mocker): + db_session_mock = mocker.MagicMock(spec=Session) + app.dependency_overrides[get_db] = lambda: db_session_mock + return db_session_mock + + +@pytest.fixture +def mock_user_service(): + with patch("api.v1.services.user.user_service", autospec=True) as user_service_mock: + yield user_service_mock + + +@pytest.fixture +def mock_blog_service(): + with patch("api.v1.services.blog.BlogService", autospec=True) as blog_service_mock: + yield blog_service_mock + + +# Test User +@pytest.fixture +def test_user(): + return User( + id=str(uuid7()), + email="testuser@gmail.com", + password="hashedpassword", + first_name="test", + last_name="user", + is_active=True, + ) + + +# Another User +@pytest.fixture +def another_user(): + return User( + id=str(uuid7()), + email="anotheruser@gmail.com", + password="hashedpassword", + first_name="another", + last_name="user", + is_active=True, + ) + +@pytest.fixture +def test_blog_dislike(test_user): + return BlogDislike( + id=str(uuid7()), + user_id=test_user.id, + blog_id=str(uuid7()), + ip_address="192.168.1.0", + created_at=datetime.now(tz=timezone.utc) + ) + +@pytest.fixture +def access_token_user(test_user): + return user_service.create_access_token(user_id=test_user.id) + +@pytest.fixture +def access_token_another(another_user): + return user_service.create_access_token(user_id=another_user.id) + + +def make_request(blog_dislike_id, token): + return client.delete( + f"/api/v1/blogs/dislikes/{blog_dislike_id}", + headers={"Authorization": f"Bearer {token}"} + ) + + +# test for successful delete +@patch("api.v1.services.blog.BlogDislikeService.fetch") +def test_successful_delete_blog_dislike( + mock_fetch_blog_dislike, + mock_db_session, + test_user, + test_blog_dislike, + access_token_user +): + # mock current-user AND blog-like + mock_db_session.query().filter().first.return_value = test_user + mock_fetch_blog_dislike.return_value = test_blog_dislike + + resp = make_request(test_blog_dislike.id, access_token_user) + assert resp.status_code == 204 + + +# Test for wrong blog like id +def test_wrong_blog_dislike_id( + mock_db_session, + test_user, + access_token_user, +): + mock_db_session.query().filter().first.return_value = test_user + mock_db_session.get.return_value = None + + ### TEST REQUEST WITH WRONG blog_dislike_id ### + resp = make_request(str(uuid7()), access_token_user) + assert resp.status_code == 404 + assert resp.json()['message'] == "BlogDislike does not exist" + + +# Test for unauthenticated user +def test_wrong_auth_token( + test_blog_dislike +): + mock_user_service.get_current_user = None + + ### TEST ATTEMPT WITH INVALID AUTH ### + resp = make_request(test_blog_dislike.id, None) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Could not validate credentials' + + +# Test for wrong owner request +def test_wrong_owner_request( + mock_db_session, + test_blog_dislike, + another_user, + access_token_another +): + mock_user_service.get_current_user = another_user + mock_db_session.get.return_value = test_blog_dislike + + ### TEST ATTEMPT BY NON OWNER ### + resp = make_request(test_blog_dislike.id, access_token_another) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Insufficient permission' \ No newline at end of file From d7d3c460d904aff098f3b5232309c6f55c967c41 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 13:31:43 +0100 Subject: [PATCH 07/30] Fix wrong reference in test delete blog like --- tests/v1/blog/test_delete_blog_like.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/v1/blog/test_delete_blog_like.py b/tests/v1/blog/test_delete_blog_like.py index 018f1f93e..7a60ace61 100644 --- a/tests/v1/blog/test_delete_blog_like.py +++ b/tests/v1/blog/test_delete_blog_like.py @@ -4,9 +4,9 @@ from sqlalchemy.orm import Session from api.db.database import get_db from datetime import datetime, timezone +from api.v1.models import User, BlogLike from fastapi.testclient import TestClient from unittest.mock import patch, MagicMock -from api.v1.models import User, BlogDislike from api.v1.services.user import user_service client = TestClient(app) @@ -58,7 +58,7 @@ def another_user(): @pytest.fixture def test_blog_like(test_user): - return BlogDislike( + return BlogLike( id=str(uuid7()), user_id=test_user.id, blog_id=str(uuid7()), From 0e700922e9782e69ee6fc19986045f8cfc6529d2 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 15:09:56 +0100 Subject: [PATCH 08/30] Finish up delete blog like endpoint --- api/v1/routes/blog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 79e88cbf9..d07ffceb1 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -311,7 +311,7 @@ async def update_blog_comment( @blog.delete("/likes/{blog_like_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_blog_like( +async def delete_blog_like( blog_like_id: str, db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), @@ -326,4 +326,4 @@ def delete_blog_like( blog_like_service = BlogLikeService(db) # delete blog like - blog_like_service.delete(blog_like_id, current_user.id) + return blog_like_service.delete(blog_like_id, current_user.id) From 6d61c373f69e9c0f4236513e206697ad5a96ea46 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 15:11:20 +0100 Subject: [PATCH 09/30] Finish up delete blog dislike endpoint --- api/v1/routes/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index a4f4632b3..fb951e801 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -326,4 +326,4 @@ def delete_blog_dislike( blog_dislike_service = BlogDislikeService(db) # delete blog dislike - blog_dislike_service.delete(blog_dislike_id, current_user.id) + return blog_dislike_service.delete(blog_dislike_id, current_user.id) From 6650ad490c2fc7d79e4a40823d2ac6a4611a66fb Mon Sep 17 00:00:00 2001 From: Okesanya Odunayo <94924061+DrInTech22@users.noreply.github.com> Date: Sat, 24 Aug 2024 15:48:08 +0100 Subject: [PATCH 10/30] Create regression-test.yml Signed-off-by: Okesanya Odunayo <94924061+DrInTech22@users.noreply.github.com> --- .github/workflows/regression-test.yml | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/regression-test.yml diff --git a/.github/workflows/regression-test.yml b/.github/workflows/regression-test.yml new file mode 100644 index 000000000..f557f679a --- /dev/null +++ b/.github/workflows/regression-test.yml @@ -0,0 +1,36 @@ +name: Run Regression Tests + +on: + schedule: + - cron: '*/15 * * * *' # Runs every 15 minutes + workflow_dispatch: + + +jobs: + run-newman-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Newman + run: | + npm install -g newman + + - name: Run Newman Tests + run: | + newman run qa_tests/regression/Core_Product.postman_collection.json -r json --reporter-json-export=result.json --suppress-exit-code + + - name: Copy result.json to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + source: "result.json" + target: "/home/${{ secrets.USERNAME }}/hng_boilerplate_python_fastapi_web/staging" From 4fe2584089bec0a1d7f8b25e58ba0046ab6f4db7 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 15:55:05 +0100 Subject: [PATCH 11/30] Fix else/if bug in services.blog.BlogService.delete_opposite_blog_like_or_dislike --- api/v1/services/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 26266ba15..305653bb2 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -149,7 +149,7 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: # delete, but do not commit yet. Allow everything # to be commited when operation like created self.db.delete(existing_dislike) - if creating == "dislike": + elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: # delete, but do not commit yet. Allow everything From ebc492f32c16c412a44f48cce9db0927cc33ef54 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Sat, 24 Aug 2024 15:55:30 +0100 Subject: [PATCH 12/30] fix: added conditionals to only send emails to new newsletter subscription --- api/v1/routes/newsletter.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/api/v1/routes/newsletter.py b/api/v1/routes/newsletter.py index 2fc6b857c..c8ddf33a6 100644 --- a/api/v1/routes/newsletter.py +++ b/api/v1/routes/newsletter.py @@ -37,20 +37,23 @@ async def sub_newsletter( # Save user to the database NewsletterService.create(db, request) - link = "https://anchor-python.teams.hng.tech/" - - # Send email in the background - background_tasks.add_task( - send_email, - recipient=request.email, - template_name="newsletter-subscription.html", - subject="Thank You for Subscribing to HNG Boilerplate Newsletters", - context={"link": link}, - ) + link = "https://anchor-python.teams.hng.tech/" + + # Send email in the background + background_tasks.add_task( + send_email, + recipient=request.email, + template_name="newsletter-subscription.html", + subject="Thank You for Subscribing to HNG Boilerplate Newsletters", + context={"link": link}, + ) + message = "Thank you for subscribing to our newsletter." + else: + message = "You have already subscribed to our newsletter. Thank you." return success_response( - message="Thank you for subscribing to our newsletter.", - status_code=status.HTTP_201_CREATED, + message=message, + status_code=status.HTTP_200_OK, ) From 2a7edc98d38a3458e5f7739064a5bed5df8925ee Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Sat, 24 Aug 2024 16:06:39 +0100 Subject: [PATCH 13/30] chore: updated with app dependencies --- requirements.txt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 615342171..e8b8a8e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ alembic==1.13.2 annotated-types==0.7.0 anyio==4.4.0 astroid==3.2.4 +async-timeout==4.0.3 attrs==23.2.0 Authlib==1.3.1 autopep8==2.3.1 @@ -23,6 +24,7 @@ colorama==0.4.6 cryptography==43.0.0 cssselect==1.2.0 cssutils==2.11.1 +Deprecated==1.2.14 dill==0.3.8 distlib==0.3.8 dnspython==2.6.1 @@ -37,17 +39,18 @@ filelock==3.15.4 flake8==7.1.0 frozenlist==1.4.1 greenlet==3.0.3 -slowapi==0.1.9 h11==0.14.0 httpcore==1.0.5 httptools==0.6.1 httpx==0.27.0 identify==2.6.0 idna==3.7 +importlib_resources==6.4.4 iniconfig==2.0.0 isort==5.13.2 itsdangerous==2.2.0 Jinja2==3.1.4 +limits==3.13.0 lxml==5.2.2 Mako==1.3.5 markdown-it-py==3.0.0 @@ -61,8 +64,8 @@ nodeenv==1.9.1 packaging==24.1 passlib==1.7.4 pathspec==0.12.1 +pillow==10.4.0 pipdeptree==2.23.1 -Pillow==10.4.0 platformdirs==4.2.2 pluggy==1.5.0 pre-commit==3.7.1 @@ -94,9 +97,11 @@ rich==13.7.1 rsa==4.9 shellingham==1.5.4 six==1.16.0 +slowapi==0.1.9 sniffio==1.3.1 SQLAlchemy==2.0.31 starlette==0.37.2 +stripe==10.7.0 tomli==2.0.1 tomlkit==0.13.0 twilio==9.2.3 @@ -110,5 +115,5 @@ virtualenv==20.26.3 watchfiles==0.22.0 webencodings==0.5.1 websockets==12.0 +wrapt==1.16.0 yarl==1.9.4 -stripe==10.7.0 From 50d2f3fdec7bfadcb561a8f7a3a3877da1d4b793 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 16:15:38 +0100 Subject: [PATCH 14/30] Fix comment in services.blog.BlogService.delete_opposite_blog_like_or_dislike --- api/v1/services/blog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 305653bb2..d2f920957 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -147,13 +147,13 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if existing_dislike: # delete, but do not commit yet. Allow everything - # to be commited when operation like created + # to be commited after the actual like is created self.db.delete(existing_dislike) elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: # delete, but do not commit yet. Allow everything - # to be commited when operation dislike created + # to be commited after the actual dislike is created self.db.delete(existing_like) else: raise HTTPException( From 50c723cfe37cbd266c08cc6f6f39c5376d442d89 Mon Sep 17 00:00:00 2001 From: Okesanya Odunayo <94924061+DrInTech22@users.noreply.github.com> Date: Sat, 24 Aug 2024 16:22:12 +0100 Subject: [PATCH 15/30] Update regression workflow --- .github/workflows/regression-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/regression-test.yml b/.github/workflows/regression-test.yml index f557f679a..7ca436d9b 100644 --- a/.github/workflows/regression-test.yml +++ b/.github/workflows/regression-test.yml @@ -24,7 +24,7 @@ jobs: - name: Run Newman Tests run: | - newman run qa_tests/regression/Core_Product.postman_collection.json -r json --reporter-json-export=result.json --suppress-exit-code + newman run qa_tests/Boilerplate-status-page.postman_collection.json -r json --reporter-json-export=result.json --suppress-exit-code - name: Copy result.json to server uses: appleboy/scp-action@v0.1.7 From 0abb5c7ca63e0c2652774fb2e13441a4a590675b Mon Sep 17 00:00:00 2001 From: Okesanya Odunayo <94924061+DrInTech22@users.noreply.github.com> Date: Sat, 24 Aug 2024 16:27:41 +0100 Subject: [PATCH 16/30] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6e228fe5e..c0782126f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ MANIFEST test_case1.py api/core/dependencies/mailjet.py tests/v1/waitlist/waitlist_test.py - +result.json # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. From 2661d529898f8450eaa78aa5e7753d06e5f1505e Mon Sep 17 00:00:00 2001 From: Okesanya Odunayo <94924061+DrInTech22@users.noreply.github.com> Date: Sat, 24 Aug 2024 17:39:50 +0100 Subject: [PATCH 17/30] Update regression workflow --- .github/workflows/regression-test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/regression-test.yml b/.github/workflows/regression-test.yml index 7ca436d9b..692edfea2 100644 --- a/.github/workflows/regression-test.yml +++ b/.github/workflows/regression-test.yml @@ -34,3 +34,18 @@ jobs: password: ${{ secrets.PASSWORD }} source: "result.json" target: "/home/${{ secrets.USERNAME }}/hng_boilerplate_python_fastapi_web/staging" + + - name: Deploy to Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + script: | + cd hng_boilerplate_python_fastapi_web/staging + git add . + git stash + git pull origin staging + python3 update_api_status.py + + From 036354ee732936b4dc772560357d343520b41da5 Mon Sep 17 00:00:00 2001 From: Oluwanifemi Date: Sat, 24 Aug 2024 18:03:34 +0100 Subject: [PATCH 18/30] feat: implement search functionality on products dashboard --- api/v1/routes/dashboard.py | 20 ++++++++++++++++---- api/v1/services/product.py | 24 ++++++++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/api/v1/routes/dashboard.py b/api/v1/routes/dashboard.py index ab23bebe5..4e9fb45a5 100644 --- a/api/v1/routes/dashboard.py +++ b/api/v1/routes/dashboard.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, Query from api.db.database import get_db from sqlalchemy.orm import Session @@ -45,12 +45,24 @@ async def get_products_count( @dashboard.get("/products", response_model=DashboardProductListResponse) async def get_products( + name: Optional[str] = Query(None, description="Search by product name"), + category: Optional[str] = Query(None, description="Filter by category"), + min_price: Optional[float] = Query( + None, description="Filter by minimum price"), + max_price: Optional[float] = Query( + None, description="Filter by maximum price"), current_user: User = Depends(user_service.get_current_super_admin), db: Session = Depends(get_db) ): - products = product_service.fetch_all(db) + products = product_service.fetch_all( + db, + name=name, + category=category, + min_price=min_price, + max_price=max_price + ) - payment_data = [ + product_data = [ { "name": prod.name, "description": prod.description, @@ -67,7 +79,7 @@ async def get_products( return success_response( status_code=200, message="Products fetched successfully", - data=payment_data + data=product_data ) diff --git a/api/v1/services/product.py b/api/v1/services/product.py index 61cce2f3a..828dab9c0 100644 --- a/api/v1/services/product.py +++ b/api/v1/services/product.py @@ -126,15 +126,24 @@ def delete(self, db: Session, org_id: str, product_id: str, current_user: User): db.commit() def fetch_all(self, db: Session, **query_params: Optional[Any]): - """Fetch all products with option tto search using query parameters""" + """Fetch all products with option to search using query parameters""" query = db.query(Product) - # Enable filter by query parameter if query_params: - for column, value in query_params.items(): - if hasattr(Product, column) and value: - query = query.filter(getattr(Product, column).ilike(f"%{value}%")) + filters = [] + if query_params.get('name'): + filters.append(Product.name.ilike(f"%{query_params['name']}%")) + if query_params.get('category'): + filters.append(Product.category.has( + ProductCategory.name.ilike(f"%{query_params['category']}%"))) + if query_params.get('min_price'): + filters.append(Product.price >= query_params['min_price']) + if query_params.get('max_price'): + filters.append(Product.price <= query_params['max_price']) + + if filters: + query = query.filter(and_(*filters)) return query.all() @@ -252,7 +261,6 @@ def fetch_all(db: Session, **query_params: Optional[Any]): ) return query.all() - - - + + product_service = ProductService() From a40f64e9a67fed6567d8a7e8b7926efed0120eaa Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 19:09:14 +0200 Subject: [PATCH 19/30] feat: enhance endpoints for plan upgrade and downgrade --- api/v1/models/billing_plan.py | 2 + api/v1/routes/stripe.py | 39 ++++--- api/v1/schemas/stripe.py | 5 +- api/v1/services/billing_plan.py | 2 +- api/v1/services/stripe_payment.py | 182 +++++++++++++++++++----------- 5 files changed, 146 insertions(+), 84 deletions(-) diff --git a/api/v1/models/billing_plan.py b/api/v1/models/billing_plan.py index a8979d5f4..e31ca556a 100644 --- a/api/v1/models/billing_plan.py +++ b/api/v1/models/billing_plan.py @@ -1,6 +1,7 @@ # app/models/billing_plan.py from sqlalchemy import Column, String, ARRAY, ForeignKey, Numeric, Boolean from sqlalchemy.orm import relationship +from sqlalchemy import DateTime from api.v1.models.base_model import BaseTableModel @@ -34,3 +35,4 @@ class UserSubscription(BaseTableModel): user = relationship("User", back_populates="subscriptions") billing_plan = relationship("BillingPlan", back_populates="user_subscriptions") organisation = relationship("Organisation", back_populates="user_subscriptions") + billing_cycle = Column(DateTime, nullable=True) diff --git a/api/v1/routes/stripe.py b/api/v1/routes/stripe.py index 22124e036..e91a7af08 100644 --- a/api/v1/routes/stripe.py +++ b/api/v1/routes/stripe.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status, Query from sqlalchemy.orm import Session import stripe from api.v1.services.stripe_payment import stripe_payment_request, \ @@ -32,10 +32,10 @@ def stripe_payment( db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user) ): - return stripe_payment_request(db, plan_upgrade_request.user_id, request, plan_upgrade_request.plan_name) + return stripe_payment_request(db, plan_upgrade_request.user_id, request, plan_upgrade_request.plan_id) @subscription_.get("/stripe/success") -def success_upgrade(session_id: str): +def success_upgrade(session_id: str= Query(...)): return success_response( status_code=status.HTTP_200_OK, message="Payment intent initiated. Please verify the payment using the session ID.", @@ -53,17 +53,17 @@ async def verify_payment(session_id: str, db: Session = Depends(get_db)): if session.payment_status == "paid": # If payment was successful, update the user's plan user_id = session.metadata["user_id"] - plan_name = session.metadata["plan_name"] - print(user_id, plan_name) - await update_user_plan(db, user_id, plan_name) - - return { "status": "SUCCESS" } - - # return success_response( - # status_code=status.HTTP_200_OK, - # message="Payment successful and plan updated.", - # data={"session_id": session_id, "payment_status": session.payment_status} - # ) + plan_id = session.metadata["plan_id"] + print(user_id, plan_id) + await update_user_plan(db, user_id, plan_id) + #TODO Remember to uncomme + # return { "status": "SUCCESS" } + + return success_response( + status_code=status.HTTP_200_OK, + message="Payment successful and plan updated.", + data={"status": "SUCCESS", "session_id": session_id, "payment_status": session.payment_status} + ) else: return fail_response( status_code=status.HTTP_400_BAD_REQUEST, @@ -77,6 +77,17 @@ async def verify_payment(session_id: str, db: Session = Depends(get_db)): raise HTTPException(status_code=500, detail=str(e)) +@subscription_.post("/stripe/change-plan") +def change_plan( + plan_upgrade_request: PlanUpgradeRequest, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + is_downgrade = plan_upgrade_request.is_downgrade + return update_user_plan(db, plan_upgrade_request.user_id, plan_upgrade_request.plan_id, is_downgrade=is_downgrade) + + @subscription_.get("/stripe/cancel") def cancel_upgrade(): diff --git a/api/v1/schemas/stripe.py b/api/v1/schemas/stripe.py index 1c445fa69..7bded814e 100644 --- a/api/v1/schemas/stripe.py +++ b/api/v1/schemas/stripe.py @@ -24,7 +24,8 @@ def cvc_validator(cls, v): class PlanUpgradeRequest(BaseModel): user_id: str - plan_name: str - payment_info: Optional[PaymentInfo] = None + plan_id: str + is_downgrade: bool + #payment_info: Optional[PaymentInfo] = None diff --git a/api/v1/services/billing_plan.py b/api/v1/services/billing_plan.py index 252eed0d4..ba2a6c9b4 100644 --- a/api/v1/services/billing_plan.py +++ b/api/v1/services/billing_plan.py @@ -47,7 +47,7 @@ def create(self, db: Session, request: CreateSubscriptionPlan): # Adjust the price if the duration is 'yearly' if request.duration == "yearly": - request.price = request.price * 12 * 0.8 # Apply yearly discount + request.price = request.price * 12 * 0.8 # Apply yearly discount of 20% # Create a BillingPlan instance using the modified request plan = BillingPlan(**request.dict()) diff --git a/api/v1/services/stripe_payment.py b/api/v1/services/stripe_payment.py index d2e0c2c11..d16e89ba8 100644 --- a/api/v1/services/stripe_payment.py +++ b/api/v1/services/stripe_payment.py @@ -2,6 +2,7 @@ from api.v1.models.user import User from api.v1.models.billing_plan import BillingPlan, UserSubscription from api.v1.models.organisation import Organisation +from api.v1.models.payment import Payment import stripe from sqlalchemy.orm import joinedload from sqlalchemy.exc import SQLAlchemyError @@ -9,12 +10,70 @@ from fastapi.encoders import jsonable_encoder from api.utils.success_response import success_response, fail_response import os +from sqlalchemy import cast, DateTime from fastapi import HTTPException, status, Request from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta stripe.api_key = os.getenv('STRIPE_SECRET_KEY') + +def get_plan_by_id(db: Session, plan_id: str): + return db.query(BillingPlan).filter(BillingPlan.id == plan_id).first() + + +def convert_duration_to_timedelta(duration: str) -> timedelta: + if duration == "monthly": + return timedelta(days=30) # Approximate month length + elif duration == "yearly": + return timedelta(days=365) # Approximate year length + else: + raise ValueError("Invalid duration") + +def is_eligible_for_plan(db: Session, user_id: str, plan_id: str): + # Fetch the user's current subscription + user_subscription = db.query(UserSubscription).filter( + UserSubscription.user_id == user_id + ).first() + + # If the user has no subscription, they are eligible for the plan + if not user_subscription: + return True + + # Check if the user's current subscription has ended + if user_subscription.end_date < datetime.utcnow(): + return True + + # If the user is trying to upgrade or downgrade, they are eligible + if user_subscription.plan_id != plan_id: + return True + + # If none of the above conditions are met, the user is not eligible + return False + + +def calculate_prorated_amount(db: Session, user_id: str, plan_id: str): + # Fetch the user's current subscription + user_subscription = db.query(UserSubscription).filter( + UserSubscription.user_id == user_id + ).first() + + # Fetch the plan the user is trying to upgrade or downgrade to + plan = get_plan_by_id(db, plan_id) + + # Calculate the number of days remaining in the current subscription + days_remaining = (user_subscription.end_date - datetime.utcnow()).days + + # Calculate the total number of days in the current subscription + total_days = (user_subscription.end_date - user_subscription.start_date).days + + # Calculate the prorated amount + prorated_amount = (plan.price / total_days) * days_remaining + + return prorated_amount + + def get_all_plans(db: Session): """ Retrieve all billing plan details. @@ -28,28 +87,77 @@ def get_all_plans(db: Session): raise HTTPException(status_code=500, detail="An error occurred while fetching billing plans") -def get_plan_by_name(db: Session, plan_name: str): - return db.query(BillingPlan).filter(BillingPlan.name == plan_name).first() +async def update_user_plan(db: Session, user_id: str, plan_id: str, is_downgrade: bool = False): + user = db.query(User).filter(User.id == user_id).first() + plan = get_plan_by_id(db, plan_id) + + try: + duration = convert_duration_to_timedelta(plan.duration) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + user_subscription = db.query(UserSubscription).filter( + UserSubscription.user_id == user_id + ).first() + if user_subscription: + old_plan = user_subscription.billing_plan + old_duration = convert_duration_to_timedelta(user_subscription.billing_plan.duration) + days_remaining = (datetime.strptime(user_subscription.end_date, "%Y-%m-%d %H:%M:%S.%f") - datetime.utcnow()).days + total_days = (datetime.strptime(user_subscription.end_date, "%Y-%m-%d %H:%M:%S.%f") - datetime.strptime(user_subscription.start_date, "%Y-%m-%d %H:%M:%S.%f")).days + + prorated_amount = 0 # Initialize prorated_amount to 0 + if is_downgrade: + prorated_amount = (old_plan.price / total_days) * days_remaining + #TODO Refund or credit the user's account (implement based on payment logic) + else: + prorated_amount = (plan.price - prorated_amount) + #TODO Charge the user's payment method (implement based on payment logic) -def stripe_payment_request(db: Session, user_id: str, request: Request, plan_name: str): + user_subscription.plan_id = plan.id + user_subscription.start_date = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f") + user_subscription.end_date = (datetime.utcnow() + duration).strftime("%Y-%m-%d %H:%M:%S.%f") + user_subscription.billing_cycle = datetime.utcnow() + duration + + else: + user_subscription = UserSubscription( + user_id=user_id, + plan_id=plan.id, + organisation_id=plan.organisation_id, + start_date=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f"), + end_date=(datetime.utcnow() + duration).strftime("%Y-%m-%d %H:%M:%S.%f"), + billing_cycle=datetime.utcnow() + duration + ) + db.add(user_subscription) + + db.commit() + db.refresh(user_subscription) + return user_subscription - # base_url = request.base_url - # base_urls = str(request.url.scheme) + "://" + str(request.url.netloc) +def stripe_payment_request(db: Session, user_id: str, request: Request, plan_id: str): + + # base_urls = request.base_url + # base_urls = str(request.url.scheme) + "://" + str(request.url.netloc) + base_urls = "https://anchor-python.teams.hng.tech/" success_url = f"{base_urls}payment" + "/success?session_id={CHECKOUT_SESSION_ID}" cancel_url = f"{base_urls}payment/pricing" + # success_url = f"{base_urls}api/v1/payment/stripe" + "/success?session_id={CHECKOUT_SESSION_ID}" + # cancel_url = f"{base_urls}api/v1/payment/stripe/cancel" + + user = db.query(User).filter(User.id == user_id).first() if not user: return fail_response(status_code=404, message="User not found") - plan = get_plan_by_name(db, plan_name) + plan = get_plan_by_id(db, plan_id) if not plan: return fail_response(status_code=404, message="Plan not found") + if plan.name != "Free": try: @@ -72,7 +180,7 @@ def stripe_payment_request(db: Session, user_id: str, request: Request, plan_nam cancel_url=cancel_url, metadata={ 'user_id': user_id, - 'plan_name': plan_name, + 'plan_id': plan.id, }, ) @@ -104,66 +212,6 @@ def stripe_payment_request(db: Session, user_id: str, request: Request, plan_nam return fail_response(status_code=400, message="No payment is required for the Free plan") -def convert_duration_to_timedelta(duration: str) -> timedelta: - if duration == "monthly": - return timedelta(days=30) # Approximate month length - elif duration == "yearly": - return timedelta(days=365) # Approximate year length - else: - raise ValueError("Invalid duration") - - -async def update_user_plan(db: Session, user_id: str, plan_name: str): - # Fetch the user by ID - user = db.query(User).filter(User.id == user_id).first() - - # Fetch the plan by name - plan = get_plan_by_name(db, plan_name) - - # Check if the user exists - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Check if the plan exists - if not plan: - raise HTTPException(status_code=404, detail="Plan not found") - - # Convert duration from string to timedelta - try: - duration = convert_duration_to_timedelta(plan.duration) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Fetch the organisation ID from the plan - organisation_id = plan.organisation_id - - # Update the user's subscription in the database - user_subscription = db.query(UserSubscription).filter( - UserSubscription.user_id == user_id, - UserSubscription.organisation_id == organisation_id - ).first() - - if user_subscription: - user_subscription.plan_id = plan.id - user_subscription.start_date = datetime.utcnow() - user_subscription.end_date = datetime.utcnow() + duration - else: - user_subscription = UserSubscription( - user_id=user_id, - plan_id=plan.id, - organisation_id=organisation_id, - start_date=datetime.utcnow(), - end_date=datetime.utcnow() + duration - ) - db.add(user_subscription) - - # Commit the transaction - db.commit() - db.refresh(user_subscription) # Refresh the session to get the updated data - - # Return the updated or newly created subscription - return user_subscription - def fetch_all_organisations_with_users_and_plans(db: Session): # Perform a join to retrieve the relevant data From a112f586e9500495cc0b1c7d7385b40902f39181 Mon Sep 17 00:00:00 2001 From: Oluwanifemi Date: Sat, 24 Aug 2024 18:37:49 +0100 Subject: [PATCH 20/30] feat: implement product search on dashboard --- api/v1/routes/dashboard.py | 20 ++-------- api/v1/routes/product.py | 76 +++++++++++++++++++++++++++++++++----- api/v1/services/product.py | 48 +++++++++++++++++------- 3 files changed, 104 insertions(+), 40 deletions(-) diff --git a/api/v1/routes/dashboard.py b/api/v1/routes/dashboard.py index 4e9fb45a5..ab23bebe5 100644 --- a/api/v1/routes/dashboard.py +++ b/api/v1/routes/dashboard.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status, Query +from fastapi import APIRouter, Depends, status from api.db.database import get_db from sqlalchemy.orm import Session @@ -45,24 +45,12 @@ async def get_products_count( @dashboard.get("/products", response_model=DashboardProductListResponse) async def get_products( - name: Optional[str] = Query(None, description="Search by product name"), - category: Optional[str] = Query(None, description="Filter by category"), - min_price: Optional[float] = Query( - None, description="Filter by minimum price"), - max_price: Optional[float] = Query( - None, description="Filter by maximum price"), current_user: User = Depends(user_service.get_current_super_admin), db: Session = Depends(get_db) ): - products = product_service.fetch_all( - db, - name=name, - category=category, - min_price=min_price, - max_price=max_price - ) + products = product_service.fetch_all(db) - product_data = [ + payment_data = [ { "name": prod.name, "description": prod.description, @@ -79,7 +67,7 @@ async def get_products( return success_response( status_code=200, message="Products fetched successfully", - data=product_data + data=payment_data ) diff --git a/api/v1/routes/product.py b/api/v1/routes/product.py index fe8c2a941..0efa71a9a 100644 --- a/api/v1/routes/product.py +++ b/api/v1/routes/product.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from sqlalchemy import func from typing import Annotated -from typing import List +from typing import List, Optional from api.utils.pagination import paginated_response from api.utils.success_response import success_response @@ -33,8 +33,10 @@ @non_organisation_product.get("", response_model=success_response, status_code=200) async def get_all_products( current_user: Annotated[User, Depends(user_service.get_current_super_admin)], - limit: Annotated[int, Query(ge=1, description="Number of products per page")] = 10, - skip: Annotated[int, Query(ge=1, description="Page number (starts from 1)")] = 0, + limit: Annotated[int, Query( + ge=1, description="Number of products per page")] = 10, + skip: Annotated[int, Query( + ge=1, description="Page number (starts from 1)")] = 0, db: Session = Depends(get_db), ): """Endpoint to get all products. Only accessible to superadmin""" @@ -62,7 +64,8 @@ def create_product_category( HTTPException: 401 FORBIDDEN (Current user is not a authenticated) """ - new_category = ProductCategoryService.create(db, category_schema, current_user) + new_category = ProductCategoryService.create( + db, category_schema, current_user) return success_response( status_code=status.HTTP_201_CREATED, @@ -98,7 +101,8 @@ def retrieve_categories( ) -product = APIRouter(prefix="/organisations/{org_id}/products", tags=["Products"]) +product = APIRouter( + prefix="/organisations/{org_id}/products", tags=["Products"]) # create @@ -249,8 +253,10 @@ def delete_product( def get_organisation_products( org_id: str, current_user: Annotated[User, Depends(user_service.get_current_user)], - limit: Annotated[int, Query(ge=1, description="Number of products per page")] = 10, - page: Annotated[int, Query(ge=1, description="Page number (starts from 1)")] = 1, + limit: Annotated[int, Query( + ge=1, description="Number of products per page")] = 10, + page: Annotated[int, Query( + ge=1, description="Page number (starts from 1)")] = 1, db: Session = Depends(get_db), ): """ @@ -326,7 +332,8 @@ async def get_products_by_filter_status( message="Products retrieved successfully", status_code=200, data=products ) except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve products") + raise HTTPException( + status_code=500, detail="Failed to retrieve products") @product.get( @@ -342,9 +349,58 @@ async def get_products_by_status( ): """Endpoint to get products by status""" try: - products = product_service.fetch_by_status(db=db, org_id=org_id, status=status) + products = product_service.fetch_by_status( + db=db, org_id=org_id, status=status) return SuccessResponse( message="Products retrieved successfully", status_code=200, data=products ) except Exception as e: - raise HTTPException(status_code=500, detail="Failed to retrieve products") + raise HTTPException( + status_code=500, detail="Failed to retrieve products") + + +@product.get("/search", status_code=status.HTTP_200_OK, response_model=ProductList) +def search_products( + org_id: str, + name: Optional[str] = Query(None, description="Search by product name"), + category: Optional[str] = Query(None, description="Filter by category"), + min_price: Optional[float] = Query( + None, description="Filter by minimum price"), + max_price: Optional[float] = Query( + None, description="Filter by maximum price"), + limit: Annotated[int, Query( + ge=1, description="Number of products per page")] = 10, + page: Annotated[int, Query( + ge=1, description="Page number (starts from 1)")] = 1, + current_user: Annotated[User, Depends( + user_service.get_current_user)] = None, + db: Session = Depends(get_db), +): + """ + Endpoint to search for products with optional filters and pagination. + + Query parameters: + - name: Search by product name + - category: Filter by category + - min_price: Filter by minimum price + - max_price: Filter by maximum price + - limit: Number of products per page (default: 10, minimum: 1) + - page: Page number (starts from 1) + """ + + products = product_service.search_products( + db=db, + org_id=org_id, + name=name, + category=category, + min_price=min_price, + max_price=max_price, + limit=limit, + page=page, + ) + + return success_response( + status_code=200, + message="Products searched successfully", + data=[jsonable_encoder(product) for product in products], + ) diff --git a/api/v1/services/product.py b/api/v1/services/product.py index 828dab9c0..c4860eb4d 100644 --- a/api/v1/services/product.py +++ b/api/v1/services/product.py @@ -126,24 +126,16 @@ def delete(self, db: Session, org_id: str, product_id: str, current_user: User): db.commit() def fetch_all(self, db: Session, **query_params: Optional[Any]): - """Fetch all products with option to search using query parameters""" + """Fetch all products with option tto search using query parameters""" query = db.query(Product) + # Enable filter by query parameter if query_params: - filters = [] - if query_params.get('name'): - filters.append(Product.name.ilike(f"%{query_params['name']}%")) - if query_params.get('category'): - filters.append(Product.category.has( - ProductCategory.name.ilike(f"%{query_params['category']}%"))) - if query_params.get('min_price'): - filters.append(Product.price >= query_params['min_price']) - if query_params.get('max_price'): - filters.append(Product.price <= query_params['max_price']) - - if filters: - query = query.filter(and_(*filters)) + for column, value in query_params.items(): + if hasattr(Product, column) and value: + query = query.filter( + getattr(Product, column).ilike(f"%{value}%")) return query.all() @@ -223,6 +215,34 @@ def fetch_stock( "last_updated": product.updated_at, } + def search_products( + db: Session, + org_id: str, + name: Optional[str] = None, + category: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + limit: int = 10, + page: int = 1, + ): + + query = db.query(Product).filter(Product.org_id == org_id) + + if name: + query = query.filter(Product.name.ilike(f"%{name}%")) + if category: + query = query.filter(Product.category.ilike(f"%{category}%")) + + if min_price is not None: + query = query.filter(Product.price >= min_price) + if max_price is not None: + query = query.filter(Product.price <= max_price) + + offset = (page - 1) * limit + products = query.offset(offset).limit(limit).all() + + return products + class ProductCategoryService(Service): """Product categories service functionality""" From 4df712732430d7ec8a746291ed25b806d50064c9 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 19:53:26 +0200 Subject: [PATCH 21/30] feat: enhance endpoints for plan upgrade and downgrade --- tests/v1/billing_plan/test_stripe.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/tests/v1/billing_plan/test_stripe.py b/tests/v1/billing_plan/test_stripe.py index c79c6aa44..f948c710d 100644 --- a/tests/v1/billing_plan/test_stripe.py +++ b/tests/v1/billing_plan/test_stripe.py @@ -68,22 +68,9 @@ def mock_fetch_all_organisations_with_users_and_plans(): with patch("api.v1.services.stripe_payment.fetch_all_organisations_with_users_and_plans") as mock_service: yield mock_service -@pytest.mark.asyncio -async def test_subscribe_user_to_plan(mock_db_session, mock_subscribe_user_to_plan): - # Mock the behavior of the service function - mock_subscribe_user_to_plan.return_value = mock_subscription - - # Call the actual service function - response = await update_user_plan(mock_db_session, user_id=user_id, plan_name="Premium") - - # Assertions - assert response.user_id == user_id - assert response.plan_id == plan_id - assert response.organisation_id == org_id - @pytest.mark.usefixtures("mock_db_session", "mock_user_service") -def test_fetch_invalid_billing_plans(mock_user_service, mock_db_session): +def test_fetch__billing_plans(mock_user_service, mock_db_session): """Billing plan fetch test.""" mock_user = create_mock_user(mock_user_service, mock_db_session) access_token = user_service.create_access_token(user_id=str(uuid7())) @@ -93,4 +80,4 @@ def test_fetch_invalid_billing_plans(mock_user_service, mock_db_session): headers={"Authorization": f"Bearer {access_token}"}, ) print(response.json()) - assert response.status_code == 404 \ No newline at end of file + assert response.status_code == 200 \ No newline at end of file From 87ac76809858695471f497c3b7a83ebf331491d1 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 20:01:04 +0200 Subject: [PATCH 22/30] feat: enhance endpoints for plan upgrade and downgrade --- tests/v1/billing_plan/test_stripe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/v1/billing_plan/test_stripe.py b/tests/v1/billing_plan/test_stripe.py index f948c710d..60fc6a3f1 100644 --- a/tests/v1/billing_plan/test_stripe.py +++ b/tests/v1/billing_plan/test_stripe.py @@ -70,7 +70,7 @@ def mock_fetch_all_organisations_with_users_and_plans(): @pytest.mark.usefixtures("mock_db_session", "mock_user_service") -def test_fetch__billing_plans(mock_user_service, mock_db_session): +def test_fetch_invalid_billing_plans(mock_user_service, mock_db_session): """Billing plan fetch test.""" mock_user = create_mock_user(mock_user_service, mock_db_session) access_token = user_service.create_access_token(user_id=str(uuid7())) @@ -80,4 +80,4 @@ def test_fetch__billing_plans(mock_user_service, mock_db_session): headers={"Authorization": f"Bearer {access_token}"}, ) print(response.json()) - assert response.status_code == 200 \ No newline at end of file + assert response.status_code == 404 \ No newline at end of file From d1b99fce8867b1d1bd5e30db7b01d2e0270e0f9a Mon Sep 17 00:00:00 2001 From: Oluwanifemi Date: Sat, 24 Aug 2024 19:03:58 +0100 Subject: [PATCH 23/30] feat:added test files to cover functionality --- tests/v1/product/test_product_search.py | 82 +++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/v1/product/test_product_search.py diff --git a/tests/v1/product/test_product_search.py b/tests/v1/product/test_product_search.py new file mode 100644 index 000000000..d845abd6f --- /dev/null +++ b/tests/v1/product/test_product_search.py @@ -0,0 +1,82 @@ +from datetime import datetime +from unittest.mock import MagicMock, patch +from fastapi import HTTPException +import pytest +from fastapi.testclient import TestClient +from main import app +from api.v1.models.user import User +from api.v1.models.product import Product +from api.db.database import get_db +from api.v1.services.user import user_service +from api.v1.services.product import product_service +from uuid_extensions import uuid7 + +client = TestClient(app) +user_id = str(uuid7()) +org_id = str(uuid7()) + + +@pytest.fixture +def mock_db_session(): + """Fixture to create a mock database session.""" + with patch("api.db.database.get_db", autospec=True) as mock_get_db: + mock_db = MagicMock() + app.dependency_overrides[get_db] = lambda: mock_db + yield mock_db + app.dependency_overrides = {} + + +@pytest.fixture +def mock_search_products(): + """Fixture to mock the search_products service function.""" + with patch("api.v1.services.product.product_service.search_products", autospec=True) as mock_search_products: + yield mock_search_products + + +@pytest.mark.asyncio +async def test_search_products_success(mock_db_session, mock_search_products): + + mock_search_products.return_value = [ + { + "id": str(uuid7()), + "name": "Test Product", + "description": "A test product", + "price": 100.0, + "category": "Test Category", + "quantity": 10, + "image_url": "http://example.com/image.jpg", + "archived": False, + "created_at": datetime.utcnow().isoformat() + } + ] + access_token = user_service.create_access_token(str(user_id)) + + response = client.get( + f"/api/v1/organisations/{org_id}/products/search?name=Test", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_search_products_no_results(mock_db_session, mock_search_products): + + mock_search_products.return_value = [] + access_token = user_service.create_access_token(str(user_id)) + + response = client.get( + f"/api/v1/organisations/{org_id}/products/search?name=NonExistentProduct", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_search_products_unauthorized(mock_db_session): + + response = client.get( + f"/api/v1/organisations/{org_id}/products/search?name=Test") + + assert response.status_code == 401 From 5ad25dcc8de67a2116763c01f1361eb83fafa5e9 Mon Sep 17 00:00:00 2001 From: Sarah Aligbe <37581442+Sarahligbe@users.noreply.github.com> Date: Sat, 24 Aug 2024 20:01:01 +0100 Subject: [PATCH 24/30] Update regression-test.yml Signed-off-by: Sarah Aligbe <37581442+Sarahligbe@users.noreply.github.com> --- .github/workflows/regression-test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/regression-test.yml b/.github/workflows/regression-test.yml index 692edfea2..cdb220e36 100644 --- a/.github/workflows/regression-test.yml +++ b/.github/workflows/regression-test.yml @@ -43,9 +43,6 @@ jobs: password: ${{ secrets.PASSWORD }} script: | cd hng_boilerplate_python_fastapi_web/staging - git add . - git stash - git pull origin staging python3 update_api_status.py From 69bb51d894cd3fe235950f3e51f8a53cd703ac62 Mon Sep 17 00:00:00 2001 From: Joshua Oloton Date: Sat, 24 Aug 2024 20:19:38 +0100 Subject: [PATCH 25/30] fix: validate status code type --- update_api_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update_api_status.py b/update_api_status.py index e60344ac1..3888c083d 100644 --- a/update_api_status.py +++ b/update_api_status.py @@ -20,7 +20,7 @@ def parse_and_post_results(): status_code = item.get('response', {}).get('code') response_time = item.get('item', {}).get('responseTime') - if status_code >= 500: + if isinstance(status_code, int) and status_code >= 500: status = 'Down' details = item.get('response', {}).get('status', 'No status available') else: From 652903576e978093fd8bea1a3505feedfae47144 Mon Sep 17 00:00:00 2001 From: Sundaymbae Date: Sat, 24 Aug 2024 23:29:05 +0200 Subject: [PATCH 26/30] fix: added email service to squeeze sign up --- .../dependencies/email/templates/squeeze.html | 56 +++++++++++++++++++ api/v1/routes/squeeze.py | 5 +- api/v1/services/squeeze.py | 17 +++++- tests/v1/squeeze_page/test_create_squeeze.py | 1 + 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 api/core/dependencies/email/templates/squeeze.html diff --git a/api/core/dependencies/email/templates/squeeze.html b/api/core/dependencies/email/templates/squeeze.html new file mode 100644 index 000000000..aa289ba48 --- /dev/null +++ b/api/core/dependencies/email/templates/squeeze.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Welcome{% endblock %} + +{% block content %} + + + + +
+
+

Welcome to Boilerplate Squeeze

+

Thanks for signing up

+
+ +
+

Hi {{name}}

+

Experience quality and innovation + like never before. Our product is made to fit your needs and make your + life easier.

+
+ +
+

Here's what you can look forward to.

+
+
    +
  • + Exclusive Offers: Enjoy special promotions and + discounts available only to our members. +
  • +
  • + Exclusive Offers: Enjoy special promotions and + discounts available only to our members. +
  • +
  • + Exclusive Offers: Enjoy special promotions and + discounts available only to our members. +
  • +
+
+
+ + + Learn more about us + + + + +
+

Regards,

+

Boilerplate

+
+
+{% endblock %} \ No newline at end of file diff --git a/api/v1/routes/squeeze.py b/api/v1/routes/squeeze.py index 1131e0edd..8256a030d 100644 --- a/api/v1/routes/squeeze.py +++ b/api/v1/routes/squeeze.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, BackgroundTasks from sqlalchemy.orm import Session from api.db.database import get_db from api.core.responses import SUCCESS @@ -13,6 +13,7 @@ @squeeze.post("", response_model=success_response, status_code=201) def create_squeeze( + background_tasks: BackgroundTasks, data: CreateSqueeze, db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_super_admin), @@ -23,7 +24,7 @@ def create_squeeze( return success_response(status.HTTP_404_NOT_FOUND, "User not found!") data.user_id = user.id data.full_name = f"{user.first_name} {user.last_name}" - new_squeeze = squeeze_service.create(db, data) + new_squeeze = squeeze_service.create(background_tasks, db, data) return success_response(status.HTTP_201_CREATED, SUCCESS, new_squeeze.to_dict()) diff --git a/api/v1/services/squeeze.py b/api/v1/services/squeeze.py index 0096a1a90..87138156e 100644 --- a/api/v1/services/squeeze.py +++ b/api/v1/services/squeeze.py @@ -1,14 +1,15 @@ -from fastapi import HTTPException +from fastapi import HTTPException, BackgroundTasks from sqlalchemy.orm import Session from api.core.base.services import Service from api.v1.models.squeeze import Squeeze +from api.core.dependencies.email_sender import send_email from api.v1.schemas.squeeze import CreateSqueeze, FilterSqueeze class SqueezeService(Service): """Squeeze service""" - def create(self, db: Session, data: CreateSqueeze): + def create(self, background_tasks: BackgroundTasks, db: Session, data: CreateSqueeze): """Create squeeze page""" new_squeeze = Squeeze( title=data.title, @@ -25,6 +26,18 @@ def create(self, db: Session, data: CreateSqueeze): db.add(new_squeeze) db.commit() db.refresh(new_squeeze) + cta_link = 'https://anchor-python.teams.hng.tech/about-us' + background_tasks.add_task( + send_email, + recipient=data.email, + template_name='squeeze.html', + subject='Welcome to HNG Squeeze', + context={ + 'name': data.full_name, + 'cta_link': cta_link + } + ) + return new_squeeze def fetch_all(self, db: Session, filter: FilterSqueeze = None): diff --git a/tests/v1/squeeze_page/test_create_squeeze.py b/tests/v1/squeeze_page/test_create_squeeze.py index 443f56785..57d63d8fd 100644 --- a/tests/v1/squeeze_page/test_create_squeeze.py +++ b/tests/v1/squeeze_page/test_create_squeeze.py @@ -78,6 +78,7 @@ def test_create_squeeze_page(mock_db_session, data): tok = client.post( LOGIN_URI, json={"email": "user1@gmail.com", "password": "P@ssw0rd"} ).json() + print(tok) assert tok["status_code"] == status.HTTP_200_OK token = tok["access_token"] res = client.post(URI, json=data, headers=theader(token)) From 675963c20cd534bf4209e1e4e7c8b1eb52bd3e84 Mon Sep 17 00:00:00 2001 From: Sundaymbae Date: Sat, 24 Aug 2024 23:53:46 +0200 Subject: [PATCH 27/30] fix: added email service to squeeze sign up --- tests/v1/squeeze_page/test_create_squeeze.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/v1/squeeze_page/test_create_squeeze.py b/tests/v1/squeeze_page/test_create_squeeze.py index 57d63d8fd..172f59ea8 100644 --- a/tests/v1/squeeze_page/test_create_squeeze.py +++ b/tests/v1/squeeze_page/test_create_squeeze.py @@ -47,6 +47,15 @@ "status_code": 201, } +# Mock the BackgroundTasks to call the task function directly +@pytest.fixture(scope='module') +def mock_send_email(): + with patch("api.core.dependencies.email_sender.send_email") as mock_email_sending: + with patch("fastapi.BackgroundTasks.add_task") as add_task_mock: + # Override the add_task method to call the function directly + add_task_mock.side_effect = lambda func, *args, **kwargs: func(*args, **kwargs) + + yield mock_email_sending @pytest.fixture def mock_db_session(_=MagicMock()): @@ -72,7 +81,7 @@ def create_mock_super_admin(_): @pytest.mark.parametrize("data", [squeeze1, squeeze2, squeeze3]) @pytest.mark.usefixtures("mock_db_session") -def test_create_squeeze_page(mock_db_session, data): +def test_create_squeeze_page(mock_db_session, data, mock_send_email): """Test create squeeze page.""" create_mock_super_admin(mock_db_session) tok = client.post( From 36787b36a42b8bb5958566f9cc0a8f7d38407491 Mon Sep 17 00:00:00 2001 From: Sundaymbae Date: Sun, 25 Aug 2024 00:00:38 +0200 Subject: [PATCH 28/30] fix: added email service to squeeze sign up --- tests/v1/squeeze_page/test_create_squeeze.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/v1/squeeze_page/test_create_squeeze.py b/tests/v1/squeeze_page/test_create_squeeze.py index 172f59ea8..de5d79540 100644 --- a/tests/v1/squeeze_page/test_create_squeeze.py +++ b/tests/v1/squeeze_page/test_create_squeeze.py @@ -1,9 +1,8 @@ import pytest from fastapi.testclient import TestClient from main import app -from api.db.database import get_db from unittest.mock import MagicMock, patch -from api.v1.models import * +from api.v1.models import User from api.v1.services.user import user_service from uuid_extensions import uuid7 from fastapi import status @@ -47,14 +46,13 @@ "status_code": 201, } -# Mock the BackgroundTasks to call the task function directly +# Mock the BackgroundTasks and email sending function @pytest.fixture(scope='module') def mock_send_email(): - with patch("api.core.dependencies.email_sender.send_email") as mock_email_sending: + with patch("api.core.dependencies.email_sender.send_email", return_value=None) as mock_email_sending: with patch("fastapi.BackgroundTasks.add_task") as add_task_mock: - # Override the add_task method to call the function directly + # Override the add_task method to simulate direct function call add_task_mock.side_effect = lambda func, *args, **kwargs: func(*args, **kwargs) - yield mock_email_sending @pytest.fixture @@ -80,7 +78,7 @@ def create_mock_super_admin(_): @pytest.mark.parametrize("data", [squeeze1, squeeze2, squeeze3]) -@pytest.mark.usefixtures("mock_db_session") +@pytest.mark.usefixtures("mock_db_session", "mock_send_email") def test_create_squeeze_page(mock_db_session, data, mock_send_email): """Test create squeeze page.""" create_mock_super_admin(mock_db_session) @@ -93,4 +91,4 @@ def test_create_squeeze_page(mock_db_session, data, mock_send_email): res = client.post(URI, json=data, headers=theader(token)) assert res.status_code == data["status_code"] assert res.json()['data']['title'] == data["title"] - assert res.json()['data']['email'] == data["email"] \ No newline at end of file + assert res.json()['data']['email'] == data["email"] From f764b977815cdd0501004dfe7147a9a54f44029a Mon Sep 17 00:00:00 2001 From: Sundaymbae Date: Sun, 25 Aug 2024 00:20:02 +0200 Subject: [PATCH 29/30] fix: added email service to squeeze sign up --- tests/v1/squeeze_page/test_fetch_squeeze.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/v1/squeeze_page/test_fetch_squeeze.py b/tests/v1/squeeze_page/test_fetch_squeeze.py index cde09f70e..aab2bdedb 100644 --- a/tests/v1/squeeze_page/test_fetch_squeeze.py +++ b/tests/v1/squeeze_page/test_fetch_squeeze.py @@ -37,6 +37,16 @@ "status_code": 201, } + +# Mock the BackgroundTasks and email sending function +@pytest.fixture(scope='module') +def mock_send_email(): + with patch("api.core.dependencies.email_sender.send_email", return_value=None) as mock_email_sending: + with patch("fastapi.BackgroundTasks.add_task") as add_task_mock: + # Override the add_task method to simulate direct function call + add_task_mock.side_effect = lambda func, *args, **kwargs: func(*args, **kwargs) + yield mock_email_sending + @pytest.fixture def mock_db_session(_=MagicMock()): """Mock session""" @@ -60,8 +70,8 @@ def create_mock_super_admin(_): @pytest.mark.parametrize("data", [squeeze1]) -@pytest.mark.usefixtures("mock_db_session") -def test_fetch_squeeze_page(mock_db_session, data): +@pytest.mark.usefixtures("mock_db_session", "mock_send_email") +def test_fetch_squeeze_page(mock_db_session, data, mock_send_email): """Test create squeeze page.""" create_mock_super_admin(mock_db_session) tok = client.post( @@ -76,8 +86,8 @@ def test_fetch_squeeze_page(mock_db_session, data): @pytest.mark.parametrize("data", [squeeze1, squeeze2]) -@pytest.mark.usefixtures("mock_db_session") -def test_fetch_all_squeeze_page(mock_db_session, data): +@pytest.mark.usefixtures("mock_db_session", "mock_send_email") +def test_fetch_all_squeeze_page(mock_db_session, data, mock_send_email): """Test create squeeze page.""" create_mock_super_admin(mock_db_session) tok = client.post( From 60c815086d7fa23f27a00f1614382d81818cbde2 Mon Sep 17 00:00:00 2001 From: theijhay Date: Sun, 25 Aug 2024 08:22:05 +0100 Subject: [PATCH 30/30] Added send email template endpoint --- api/v1/routes/email_template.py | 23 +++- api/v1/schemas/email_template.py | 8 +- api/v1/services/email_template.py | 31 +++++- seed.py | 8 +- .../test_send_email_template.py | 105 ++++++++++++++++++ 5 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/v1/email_template/test_send_email_template.py diff --git a/api/v1/routes/email_template.py b/api/v1/routes/email_template.py index 35346da26..91eb1c873 100644 --- a/api/v1/routes/email_template.py +++ b/api/v1/routes/email_template.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, HTTPException from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session @@ -92,3 +92,24 @@ async def delete_email_template( """Endpoint to delete a single template""" email_template_service.delete(db, template_id=template_id) + + +@email_template.post("/{template_id}/send", response_model=success_response, status_code=200) +async def send_email_template( + template_id: str, + recipient_email: str, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_super_admin), +): + """Endpoint to send an email template to a recipient""" + + send_result = email_template_service.send(db=db, template_id=template_id, recipient_email=recipient_email) + + if send_result["status"] == "failure": + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=send_result["message"]) + + return success_response( + data=send_result, + message=f"Email sent successfully to {recipient_email}", + status_code=status.HTTP_200_OK, + ) diff --git a/api/v1/schemas/email_template.py b/api/v1/schemas/email_template.py index 1862866db..75f6621b3 100644 --- a/api/v1/schemas/email_template.py +++ b/api/v1/schemas/email_template.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, field_validator import bleach +from enum import Enum ALLOWED_TAGS = [ 'a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', @@ -30,12 +31,15 @@ def sanitize_html(template: str) -> str: ) return cleaned_html -class EmailTemplateSchema(BaseModel): +class TemplateStatusEnum(str, Enum): + online = 'online' + offline = 'offline' +class EmailTemplateSchema(BaseModel): title: str template: str type: str - status: str = 'online' + template_status: TemplateStatusEnum | None = TemplateStatusEnum.online # Default to 'online' @field_validator("template") @classmethod diff --git a/api/v1/services/email_template.py b/api/v1/services/email_template.py index 5ca01a840..97d5f6bad 100644 --- a/api/v1/services/email_template.py +++ b/api/v1/services/email_template.py @@ -4,19 +4,20 @@ from api.v1.models.email_template import EmailTemplate from api.v1.schemas.email_template import EmailTemplateSchema from api.utils.db_validators import check_model_existence +import logging +import time + class EmailTemplateService(Service): '''Email template service functionality''' def create(self, db: Session, schema: EmailTemplateSchema): - """Create a new FAQ""" - + """Create a new Email Template""" new_template = EmailTemplate(**schema.model_dump()) db.add(new_template) db.commit() db.refresh(new_template) - return new_template def fetch_all(self, db: Session, **query_params: Optional[Any]): @@ -58,6 +59,30 @@ def delete(self, db: Session, template_id: str): template = self.fetch(db=db, template_id=template_id) db.delete(template) db.commit() + + + def send(self, db: Session, template_id: str, recipient_email: str, max_retries: int = 3): + """Send an email template to a recipient with error handling and retries""" + + template = self.fetch(db=db, template_id=template_id) + for attempt in range(max_retries): + try: + self._send_email(recipient_email, template) + logging.info(f"Template {template_id} sent successfully to {recipient_email}") + return {"status": "success", "message": f"Email sent to {recipient_email}"} + + except Exception as e: + logging.error(f"Attempt {attempt + 1} failed to send template {template_id}: {e}") + time.sleep(2 ** attempt) + + logging.error(f"All attempts to send template {template_id} to {recipient_email} failed.") + return {"status": "failure", "message": "Failed to send email after multiple attempts"} + + def _send_email(self, recipient_email: str, template: EmailTemplate): + """Mock email sending function""" + if not recipient_email or not template: + raise ValueError("Invalid recipient email or template.") + pass email_template_service = EmailTemplateService() diff --git a/seed.py b/seed.py index b4423180c..1a29666b1 100644 --- a/seed.py +++ b/seed.py @@ -8,10 +8,10 @@ admin_user = User( - email="freeman@example.com", - password=user_service.hash_password("supersecret"), - first_name="Habeeb", - last_name="Habeeb", + email="Isaacj@gmail.com", + password=user_service.hash_password("45@&tuTU"), + first_name="Isaac", + last_name="John", is_active=True, is_superadmin=True, is_deleted=False, diff --git a/tests/v1/email_template/test_send_email_template.py b/tests/v1/email_template/test_send_email_template.py new file mode 100644 index 000000000..b524b3979 --- /dev/null +++ b/tests/v1/email_template/test_send_email_template.py @@ -0,0 +1,105 @@ +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from datetime import datetime, timezone +from uuid_extensions import uuid7 +import pytest + +from api.db.database import get_db +from api.v1.services.user import user_service +from api.v1.models import User +from api.v1.models.email_template import EmailTemplate +from api.v1.services.email_template import email_template_service +from main import app + + +# Mocked data and services +def mock_get_current_admin(): + return User( + id=str(uuid7()), + email="admin@gmail.com", + password=user_service.hash_password("Testadmin@123"), + first_name='Admin', + last_name='User', + is_active=True, + is_superadmin=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + +def mock_email_template(): + return EmailTemplate( + id=str(uuid7()), + title="Test title", + type="Test type", + template="

Hello

", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + +@pytest.fixture +def db_session_mock(): + db_session = MagicMock(spec=Session) + return db_session + + +@pytest.fixture +def client(db_session_mock): + app.dependency_overrides[get_db] = lambda: db_session_mock + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +def test_send_email_template_success(client, db_session_mock): + """Test successfully sending an email template""" + + # Mock the current admin user and email template + app.dependency_overrides[user_service.get_current_super_admin] = lambda: mock_get_current_admin() + app.dependency_overrides[email_template_service.fetch] = lambda db, template_id: mock_email_template() + + with patch("api.v1.services.email_template.email_template_service.send") as mock_send: + mock_send.return_value = {"status": "success", "message": "Email sent to recipient@example.com"} + + # recipient_email is passed as a query parameter now + response = client.post( + f'/api/v1/email-templates/{mock_email_template().id}/send?recipient_email=recipient@example.com', + headers={'Authorization': 'Bearer token'}, + ) + + assert response.status_code == 200 + assert response.json()["message"] == "Email sent successfully to recipient@example.com" + + +def test_send_email_template_failure(client, db_session_mock): + """Test failing to send an email template with retry mechanism""" + + # Mock the current admin user and email template + app.dependency_overrides[user_service.get_current_super_admin] = lambda: mock_get_current_admin() + app.dependency_overrides[email_template_service.fetch] = lambda db, template_id: mock_email_template() + + with patch("api.v1.services.email_template.email_template_service.send") as mock_send: + mock_send.return_value = {"status": "failure", "message": "Failed to send email after multiple attempts"} + + response = client.post( + f'/api/v1/email-templates/{mock_email_template().id}/send?recipient_email=recipient@example.com', + headers={'Authorization': 'Bearer token'}, + ) + + assert response.status_code == 500 + assert response.json()["message"] == "Failed to send email after multiple attempts" + + + + +def test_send_email_template_unauthorized(client, db_session_mock): + """Test sending email template by unauthorized user""" + + response = client.post( + f'/api/v1/email-templates/{mock_email_template().id}/send', + json={"recipient_email": "recipient@example.com"}, + ) + + assert response.status_code == 401