From 6c002fc7a48d5c41c59b1a4b4cd82998d45620cd Mon Sep 17 00:00:00 2001 From: Chime Date: Fri, 23 Aug 2024 12:52:25 +0100 Subject: [PATCH 01/34] Update billing plan endpoints schemas to show sample return data in docs --- api/v1/routes/billing_plan.py | 25 ++++++++++++++----------- api/v1/schemas/base_schema.py | 16 ++++++++++++++++ api/v1/schemas/plans.py | 29 ++++++++++++++++------------- api/v1/services/billing_plan.py | 4 ++-- 4 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 api/v1/schemas/base_schema.py diff --git a/api/v1/routes/billing_plan.py b/api/v1/routes/billing_plan.py index 701f91f9f..dd467996a 100644 --- a/api/v1/routes/billing_plan.py +++ b/api/v1/routes/billing_plan.py @@ -10,12 +10,15 @@ from api.v1.services.billing_plan import billing_plan_service from api.db.database import get_db from api.v1.services.user import user_service -from api.v1.schemas.plans import CreateSubscriptionPlan +from api.v1.schemas.plans import ( + CreateBillingPlanSchema, CreateBillingPlanResponse, GetBillingPlanListResponse +) + bill_plan = APIRouter(prefix="/organisations", tags=["Billing-Plan"]) -@bill_plan.get("/{organisation_id}/billing-plans", response_model=success_response) +@bill_plan.get("/{organisation_id}/billing-plans", response_model=GetBillingPlanListResponse) async def retrieve_all_billing_plans( organisation_id: str, db: Session = Depends(get_db) ): @@ -34,10 +37,10 @@ async def retrieve_all_billing_plans( ) -@bill_plan.post("/billing-plans", response_model=success_response) +@bill_plan.post("/billing-plans", response_model=CreateBillingPlanResponse) async def create_new_billing_plan( - request: CreateSubscriptionPlan, - current_user: User = Depends(user_service.get_current_super_admin), + request: CreateBillingPlanSchema, + _: User = Depends(user_service.get_current_super_admin), db: Session = Depends(get_db), ): """ @@ -53,11 +56,11 @@ async def create_new_billing_plan( ) -@bill_plan.patch("/billing-plans/{billing_plan_id}", response_model=success_response) +@bill_plan.patch("/billing-plans/{billing_plan_id}", response_model=CreateBillingPlanResponse) async def update_a_billing_plan( billing_plan_id: str, - request: CreateSubscriptionPlan, - current_user: User = Depends(user_service.get_current_super_admin), + request: CreateBillingPlanSchema, + _: User = Depends(user_service.get_current_super_admin), db: Session = Depends(get_db), ): """ @@ -76,7 +79,7 @@ async def update_a_billing_plan( @bill_plan.delete("/billing-plans/{billing_plan_id}", response_model=success_response) async def delete_a_billing_plan( billing_plan_id: str, - current_user: User = Depends(user_service.get_current_super_admin), + _: User = Depends(user_service.get_current_super_admin), db: Session = Depends(get_db), ): """ @@ -91,11 +94,11 @@ async def delete_a_billing_plan( ) -@bill_plan.get('/billing-plans/{billing_plan_id}', response_model=success_response) +@bill_plan.get('/billing-plans/{billing_plan_id}', response_model=CreateBillingPlanResponse) async def retrieve_single_billing_plans( billing_plan_id: str, db: Session = Depends(get_db), - current_user: User = Depends(user_service.get_current_user) + _: User = Depends(user_service.get_current_user) ): """ Endpoint to get single billing plan by id diff --git a/api/v1/schemas/base_schema.py b/api/v1/schemas/base_schema.py new file mode 100644 index 000000000..76fe51c7d --- /dev/null +++ b/api/v1/schemas/base_schema.py @@ -0,0 +1,16 @@ +from typing import List +from datetime import datetime +from pydantic import BaseModel + + +class ResponseBase(BaseModel): + status_code: int = 200 + success: bool + message: str + + +class PaginationBase(BaseModel): + limit: int + offset: int + pages: int + total_items: int diff --git a/api/v1/schemas/plans.py b/api/v1/schemas/plans.py index ce6bb4729..931b5b97b 100644 --- a/api/v1/schemas/plans.py +++ b/api/v1/schemas/plans.py @@ -1,8 +1,11 @@ from pydantic import BaseModel, validator from typing import List, Optional +from datetime import datetime +from api.v1.schemas.base_schema import ResponseBase -class CreateSubscriptionPlan(BaseModel): + +class CreateBillingPlanSchema(BaseModel): name: str description: Optional[str] = None price: int @@ -26,22 +29,22 @@ def validate_duration(cls, value): return v -class SubscriptionPlanResponse(CreateSubscriptionPlan): +class CreateBillingPlanReturnData(CreateBillingPlanSchema): id: str + created_at: datetime + updated_at: datetime class Config: from_attributes = True -class BillingPlanSchema(BaseModel): - id: str - organisation_id: str - name: str - price: float - currency: str - duration: str - description: Optional[str] = None - features: List[str] +class CreateBillingPlanResponse(ResponseBase): + data: CreateBillingPlanReturnData - class Config: - orm_mode = True \ No newline at end of file + +class GetBillingPlanData(BaseModel): + billing_plans: List[CreateBillingPlanReturnData] + + +class GetBillingPlanListResponse(ResponseBase): + data: GetBillingPlanData \ No newline at end of file diff --git a/api/v1/services/billing_plan.py b/api/v1/services/billing_plan.py index 252eed0d4..364094eb8 100644 --- a/api/v1/services/billing_plan.py +++ b/api/v1/services/billing_plan.py @@ -3,7 +3,7 @@ from api.v1.models.billing_plan import BillingPlan from typing import Any, Optional from api.core.base.services import Service -from api.v1.schemas.plans import CreateSubscriptionPlan +from api.v1.schemas.plans import CreateBillingPlanSchema from api.utils.db_validators import check_model_existence from fastapi import HTTPException, status @@ -11,7 +11,7 @@ class BillingPlanService(Service): """Product service functionality""" - def create(self, db: Session, request: CreateSubscriptionPlan): + def create(self, db: Session, request: CreateBillingPlanSchema): """ Create and return a new billing plan, ensuring a plan name can only exist once for each 'monthly' and 'yearly' duration, and cannot be created From c91bce49f7d4d89c9aac2c82bce2fcb071423cf8 Mon Sep 17 00:00:00 2001 From: Chime Date: Fri, 23 Aug 2024 14:21:15 +0100 Subject: [PATCH 02/34] Correct billing_plans to plans --- api/v1/schemas/plans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/schemas/plans.py b/api/v1/schemas/plans.py index 931b5b97b..18bddb4a3 100644 --- a/api/v1/schemas/plans.py +++ b/api/v1/schemas/plans.py @@ -43,7 +43,7 @@ class CreateBillingPlanResponse(ResponseBase): class GetBillingPlanData(BaseModel): - billing_plans: List[CreateBillingPlanReturnData] + plans: List[CreateBillingPlanReturnData] class GetBillingPlanListResponse(ResponseBase): From 6b3075cda2be95f3f806e62a341d49b8243ccbed Mon Sep 17 00:00:00 2001 From: theijhay Date: Sat, 24 Aug 2024 00:49:42 +0100 Subject: [PATCH 03/34] 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 afc319e66ce4db4a38211c8d0e93c855ecef7b71 Mon Sep 17 00:00:00 2001 From: utibe solomon Date: Sat, 24 Aug 2024 02:31:22 +0100 Subject: [PATCH 04/34] feat added get all payments with user_id --- api/v1/routes/payment.py | 39 +++++++++++++-- .../test_get_payments_for_current_user.py | 10 ++-- tests/v1/payment/test_payment.py | 49 +++++++++++++++++-- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/api/v1/routes/payment.py b/api/v1/routes/payment.py index ea22ca03c..5a5e1a357 100644 --- a/api/v1/routes/payment.py +++ b/api/v1/routes/payment.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter, status, Query, HTTPException from sqlalchemy.orm import Session from typing import Annotated - +from fastapi.encoders import jsonable_encoder from api.utils.success_response import success_response from api.v1.schemas.payment import PaymentListResponse, PaymentResponse from api.v1.services.payment import PaymentService @@ -9,8 +9,9 @@ from api.db.database import get_db from api.v1.models import User -payment = APIRouter(prefix="/payments", tags=["Payments"]) +payment = APIRouter(prefix="/transactions", tags=["Transactions"]) +payment_service = PaymentService() @payment.get( "/current-user", status_code=status.HTTP_200_OK, response_model=PaymentListResponse @@ -28,7 +29,7 @@ def get_payments_for_current_user( - limit: Number of payment per page (default: 10, minimum: 1) - page: Page number (starts from 1) """ - payment_service = PaymentService() + # FETCH all payments for current user payments = payment_service.fetch_by_user( @@ -78,14 +79,42 @@ def get_payments_for_current_user( data=data, ) +@payment.get("/user/{user_id}", response_model=success_response, status_code=status.HTTP_200_OK) +def get_user_payments_by_id( + user_id : str, + current_user : Annotated[User , Depends(user_service.get_current_user)], + db : Annotated[Session, Depends(get_db)], + page_size: Annotated[int, Query(ge=1, description="Number of payments per page")] = 10, + page_number: Annotated[int, Query(ge=1, description="Page number (starts from 1)")] = 1 + ): + """Functions that handles get all transactions for a user by id with endpoint + + Args: + user_id (str): Identifier of the user + current_user (Annotated[User , Depends): Dependency to get the current User + page_size (Annotated[int, Query, optional): The total amount of instances to be returned per page. Defaults to 1, description="Number of payments per page")]=10. + page_number (Annotated[int, Query, optional): page number to be viewed. Defaults to 1, description="Page number (starts from 1)")]=1. + + + """ + payments = payment_service.fetch_by_user( + db=db, + user_id=user_id, + limit=page_size, + page=page_number + ) + return success_response( + status_code=status.HTTP_200_OK, + message='Payments retrieved', + data=[jsonable_encoder(payment) for payment in payments] + ) + @payment.get("/{payment_id}", response_model=PaymentResponse) async def get_payment(payment_id: str, db: Session = Depends(get_db)): ''' Endpoint to retrieve a payment by its ID. ''' - - payment_service = PaymentService() payment = payment_service.get_payment_by_id(db, payment_id) return payment diff --git a/tests/v1/payment/test_get_payments_for_current_user.py b/tests/v1/payment/test_get_payments_for_current_user.py index f27f8083c..d49716135 100644 --- a/tests/v1/payment/test_get_payments_for_current_user.py +++ b/tests/v1/payment/test_get_payments_for_current_user.py @@ -74,7 +74,7 @@ def test_get_payments_successfully( # Make request params = {'page': 1, 'limit': 10} headers = {'Authorization': f'Bearer {access_token_user}'} - response = client.get("/api/v1/payments/current-user", params=params, headers=headers) + response = client.get("/api/v1/transactions/current-user", params=params, headers=headers) resp_d = response.json() @@ -107,7 +107,7 @@ def test_get_payments_successfully( # Make request, with limit set to 2, to get 3 pages params = {'page': 1, 'limit': 2} headers = {'Authorization': f'Bearer {access_token_user}'} - response = client.get("/api/v1/payments/current-user", params=params, headers=headers) + response = client.get("/api/v1/transactions/current-user", params=params, headers=headers) resp_d = response.json() @@ -139,14 +139,14 @@ def test_for_unauthenticated_get_payments( # Make request || WRONG Authorization headers = {'Authorization': f'Bearer {random_access_token}'} - response = client.get("/api/v1/payments/current-user", params=params, headers=headers) + response = client.get("/api/v1/transactions/current-user", params=params, headers=headers) assert response.status_code == 401 assert response.json()['message'] == "Could not validate credentials" assert not response.json().get('data') # Make request || NO Authorization - response = client.get("/api/v1/payments/current-user", params=params) + response = client.get("/api/v1/transactions/current-user", params=params) assert response.status_code == 401 assert response.json()['message'] == "Not authenticated" @@ -170,7 +170,7 @@ def test_for_no_payments_for_user( # Make request params = {'page': 1, 'limit': 10} headers = {'Authorization': f'Bearer {access_token_user}'} - response = client.get("/api/v1/payments/current-user", params=params, headers=headers) + response = client.get("/api/v1/transactions/current-user", params=params, headers=headers) assert response.status_code == 404 assert response.json()['message'] == "Payments not found for user" diff --git a/tests/v1/payment/test_payment.py b/tests/v1/payment/test_payment.py index 23564badf..a89ada120 100644 --- a/tests/v1/payment/test_payment.py +++ b/tests/v1/payment/test_payment.py @@ -3,10 +3,15 @@ from datetime import datetime from fastapi.testclient import TestClient from main import app +from unittest.mock import MagicMock +from api.v1.services.user import user_service from api.v1.services.payment import PaymentService from api.v1.schemas.payment import PaymentResponse from api.utils.db_validators import check_model_existence - +from api.db.database import get_db +from uuid_extensions import uuid7 +from datetime import timezone +from api.v1.models.user import User client = TestClient(app) mock_payment = { @@ -21,16 +26,54 @@ "updated_at": datetime(2024, 7, 28, 12, 31, 36, 650997) } +@pytest.fixture +def db_session_mock(): + db = MagicMock() + yield db + +@pytest.fixture(autouse=True) +def override_get_db(db_session_mock): + def get_db_override(): + yield db_session_mock + + app.dependency_overrides[get_db] = get_db_override + yield + # Clean up after the test by removing the override + app.dependency_overrides = {} + + +mock_user = User( + id=str(uuid7()), + email="dummyuser1@gmail.com", + password=user_service.hash_password("Testpassword@123"), + first_name="Mr", + last_name="Dummy", + is_active=True, + is_superadmin=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + def test_get_payment(mocker): mocker.patch.object(PaymentService, 'get_payment_by_id', return_value=mock_payment) - response = client.get(f"/api/v1/payments/{mock_payment['id']}") + response = client.get(f"/api/v1/transactions/{mock_payment['id']}") assert response.status_code == 200 # assert response.json() == PaymentResponse(**mock_payment).model_dump() def test_get_payment_not_found(mocker): mocker.patch.object(PaymentService, 'get_payment_by_id', side_effect=HTTPException(status_code=404, detail='Payment does not exist')) - response = client.get("/api/v1/payments/non_existent_id") + response = client.get("/api/v1/transactions/non_existent_id") assert response.status_code == 404 +def test_get_payments_by_user_id(db_session_mock): + app.dependency_overrides[user_service.get_current_user] = lambda : mock_user + db_session_mock.get.return_value = mock_user + db_session_mock.query().all.return_value = [mock_payment] + response = client.get(f'/api/v1/transactions/user/{mock_user.id}') + assert response.status_code == 200 + + + From 4f330474f8463db0dcf4bcdc843d9af810c3a196 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 04:49:18 +0200 Subject: [PATCH 05/34] bugfix: updated google auth --- api/v1/routes/google_login.py | 131 ++++++++++++++++++++-------- api/v1/routes/stripe.py | 1 + api/v1/services/google_oauth.py | 25 +++++- api/v1/services/payment.py | 2 - api/v1/services/schedule_payment.py | 0 api/v1/services/user.py | 19 ++++ tests/v1/auth/test_google_auth.py | 107 +++++++++++++++++++++++ 7 files changed, 242 insertions(+), 43 deletions(-) create mode 100644 api/v1/services/schedule_payment.py create mode 100644 tests/v1/auth/test_google_auth.py diff --git a/api/v1/routes/google_login.py b/api/v1/routes/google_login.py index 7d009da01..af94352d2 100644 --- a/api/v1/routes/google_login.py +++ b/api/v1/routes/google_login.py @@ -24,47 +24,102 @@ @google_auth.post("/google", status_code=200) async def google_login(background_tasks: BackgroundTasks, token_request: OAuthToken, db: Session = Depends(get_db)): + """ + Handles Google OAuth login. + + Args: + - background_tasks (BackgroundTasks): Background tasks to be executed. + - token_request (OAuthToken): OAuth token request. + - db (Session): Database session. - google_oauth_service = GoogleOauthServices() + Returns: + - JSONResponse: JSON response with user details and access token. - id_token = token_request.id_token - profile_endpoint = f'https://www.googleapis.com/oauth2/v3/tokeninfo?id_token={id_token}' - profile_response = requests.get(profile_endpoint) + Example: + ``` + POST /google HTTP/1.1 + Content-Type: application/json + + { + "id_token": "your_id_token_here" + } + ``` + """ + try: + + id_token = token_request.id_token + profile_endpoint = f'https://www.googleapis.com/oauth2/v3/tokeninfo?id_token={id_token}' + profile_response = requests.get(profile_endpoint) + + if profile_response.status_code != 200: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token or failed to fetch user info") + + profile_data = profile_response.json() + + + email = profile_data.get('email') + user = user_service.get_user_by_email(db=db, email=email) + + # Check if the user exists + if user: + # User already exists, return their details + access_token = user_service.create_access_token(user_id=user.id) + refresh_token = user_service.create_refresh_token(user_id=user.id) + response = JSONResponse( + status_code=200, + content={ + "status_code": 200, + "message": "Login successful", + "access_token": access_token, + "data": { + "user": jsonable_encoder( + user, exclude=["password", "is_deleted", "updated_at"] + ) + }, + }, + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + expires=timedelta(days=30), + httponly=True, + secure=True, + samesite="none", + ) + return response + else: + + google_oauth_service = GoogleOauthServices() + # User does not exist, create a new user + user = google_oauth_service.create(background_tasks=background_tasks, db=db, google_response=profile_data) + access_token = user_service.create_access_token(user_id=user.id) + refresh_token = user_service.create_refresh_token(user_id=user.id) + response = JSONResponse( + status_code=200, + content={ + "status_code": 200, + "message": "Login successful", + "access_token": access_token, + "data": { + "user": jsonable_encoder( + user, exclude=["password", "is_deleted", "updated_at"] + ) + }, + }, + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + expires=timedelta(days=30), + httponly=True, + secure=True, + samesite="none", + ) + return response + except ValueError: + # Invalid ID token + return JSONResponse(status_code=401, content={"error": "Invalid ID token"}) - if profile_response.status_code != 200: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token or failed to fetch user info") - - profile_data = profile_response.json() - user = google_oauth_service.create(background_tasks=background_tasks, db=db, google_response=profile_data) - - access_token = user_service.create_access_token(user_id=user.id) - refresh_token = user_service.create_refresh_token(user_id=user.id) - - response = JSONResponse( - status_code=200, - content={ - "status_code": 200, - "message": "Successfully authenticated", - "access_token": access_token, - "data": { - "user": jsonable_encoder( - user, - exclude=['password', 'is_superadmin', 'is_deleted', 'is_verified', 'updated_at'] - ) - } - } - ) - - response.set_cookie( - key="refresh_token", - value=refresh_token, - expires=timedelta(days=60), - httponly=True, - secure=True, - samesite="none", - ) - - return response @google_auth.get("/callback/google") diff --git a/api/v1/routes/stripe.py b/api/v1/routes/stripe.py index c445743cc..22124e036 100644 --- a/api/v1/routes/stripe.py +++ b/api/v1/routes/stripe.py @@ -82,6 +82,7 @@ def cancel_upgrade(): return success_response(status_code=status.HTTP_200_OK, message="Payment intent canceled") +#TODO create automatic billing cycle based on when initial billing end date @subscription_.get("/plans") def get_plans( diff --git a/api/v1/services/google_oauth.py b/api/v1/services/google_oauth.py index bdf7da1f7..090659233 100644 --- a/api/v1/services/google_oauth.py +++ b/api/v1/services/google_oauth.py @@ -206,26 +206,45 @@ def create_new_user( profile = Profile(user_id=new_user.id, avatar_url=google_response.get("picture")) + db.add(profile) + db.commit() + db.refresh(profile) + oauth_data = OAuth( provider="google", user_id=new_user.id, sub=google_response.get("sub") ) + db.add(oauth_data) + db.commit() + db.refresh(oauth_data) + organisation = Organisation( name = f'{new_user.email} {new_user.last_name} Organisation' ) + + db.add(organisation) + db.commit() + db.refresh(organisation) region = Region( user_id=new_user.id, region='Empty' ) + db.add(region) + db.commit() + db.refresh(region) + # Create notification settings directly for the user notification_setting_service.create(db=db, user=new_user) + # create data privacy setting data_privacy = DataPrivacySetting(user_id=new_user.id) - - db.add_all([profile, oauth_data, organisation, region, data_privacy]) + db.add(data_privacy) + db.commit() + db.refresh(data_privacy) + #db.add_all([profile, oauth_data, organisation, region, data_privacy]) news_letter = db.query(NewsletterSubscriber).filter_by(email=new_user.email) if not news_letter: @@ -236,8 +255,8 @@ def create_new_user( user_id=new_user.id, organisation_id=organisation.id, role="owner" ) db.execute(stmt) - db.commit() return new_user + except Exception as e: raise HTTPException(status_code=500, detail=f'Error {e}') diff --git a/api/v1/services/payment.py b/api/v1/services/payment.py index d9bd17261..a800ad0e6 100644 --- a/api/v1/services/payment.py +++ b/api/v1/services/payment.py @@ -1,7 +1,5 @@ from typing import Any, Optional from sqlalchemy.orm import Session -from api.v1.models.payment import Payment - from fastapi import HTTPException from api.v1.models import User from api.v1.models.payment import Payment diff --git a/api/v1/services/schedule_payment.py b/api/v1/services/schedule_payment.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/v1/services/user.py b/api/v1/services/user.py index 0ff459885..6d1d40262 100644 --- a/api/v1/services/user.py +++ b/api/v1/services/user.py @@ -120,7 +120,26 @@ def get_user_by_id(self, db: Session, id: str): user = check_model_existence(db, User, id) return user + + + def get_user_by_email(self, db: Session, email: str) -> Optional[User]: + """ + Fetches a user by their email address. + + Args: + db: The database session. + email: The email address of the user. + + Returns: + The user object if found, otherwise None. + """ + user = db.query(User).filter(User.email == email).first() + if not user: + return None + + return user + def fetch_by_email(self, db: Session, email): """Fetches a user by their email""" diff --git a/tests/v1/auth/test_google_auth.py b/tests/v1/auth/test_google_auth.py new file mode 100644 index 000000000..d997e3bcf --- /dev/null +++ b/tests/v1/auth/test_google_auth.py @@ -0,0 +1,107 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock +from requests.models import Response +from main import app +from api.v1.services.user import user_service +from api.v1.models.user import User +from api.db.database import get_db +from uuid_extensions import uuid7 +from datetime import datetime, timezone +from fastapi import status +from fastapi.encoders import jsonable_encoder + +client = TestClient(app) + +@pytest.fixture +def mock_db_session(): + """Fixture to create a mock database session.""" + with patch("api.v1.services.user.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_user_service(): + """Fixture to create a mock user service.""" + with patch("api.v1.services.user.user_service", autospec=True) as mock_service: + yield mock_service + +@pytest.fixture +def mock_google_oauth_service(): + """Fixture to create a mock Google OAuth service.""" + with patch("api.v1.services.google_oauth.GoogleOauthServices", autospec=True) as mock_service: + yield mock_service + +@pytest.mark.usefixtures("mock_db_session", "mock_user_service", "mock_google_oauth_service") +def test_google_login_existing_user(mock_user_service, mock_google_oauth_service, mock_db_session): + """Test Google login for an existing user.""" + email = "existinguser@example.com" + mock_id_token = "mocked_id_token" + + # Mock user data + mock_user = User( + id=str(uuid7()), + email=email, + first_name='Existing', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + mock_db_session.query.return_value.filter.return_value.first.return_value = mock_user + # Mock user service responses + mock_user_service.get_user_by_email.return_value = mock_user + mock_user_service.create_access_token.return_value = "mock_access_token" + mock_user_service.create_refresh_token.return_value = "mock_refresh_token" + + # Mock Google OAuth token info response + with patch("requests.get") as mock_get: + mock_response = MagicMock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"email": email} + mock_get.return_value = mock_response + + # Perform the API request + response = client.post("api/v1/auth/google", json={"id_token": "mock_id_token"}) + # Assertions + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert response_json["message"] == "Login successful" + assert response_json["data"]["user"]["email"] == email + +@pytest.mark.usefixtures("mock_db_session", "mock_user_service", "mock_google_oauth_service") +def test_google_login_new_user(mock_user_service, mock_google_oauth_service, mock_db_session): + """Test Google login for a new user.""" + email = "newuser@example.com" + mock_id_token = "mocked_id_token" + + # Mock Google OAuth token info response + with patch("requests.get") as mock_get: + mock_response = MagicMock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"email": email} + mock_get.return_value = mock_response + + # Mock user retrieval returning None (new user) + mock_user_service.get_user_by_email.return_value = None + + # Mock the GoogleOauthServices create method + mock_user = User( + id=str(uuid7()), + email=email, + first_name='New', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + mock_db_session.query.return_value.filter.return_value.first.return_value = mock_user + + # Perform the API request + response = client.post("api/v1/auth/google", json={"id_token": mock_id_token}) + + # Assertions + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert response_json["message"] == "Login successful" + assert response_json["data"]["user"]["email"] == email \ No newline at end of file From 6aaef3f3f4304fdc59978f86f22a5d9ae5cd51c9 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 04:56:30 +0200 Subject: [PATCH 06/34] bugfix: updated google auth --- tests/v1/auth/test_google_auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/v1/auth/test_google_auth.py b/tests/v1/auth/test_google_auth.py index d997e3bcf..755dde351 100644 --- a/tests/v1/auth/test_google_auth.py +++ b/tests/v1/auth/test_google_auth.py @@ -103,5 +103,4 @@ def test_google_login_new_user(mock_user_service, mock_google_oauth_service, moc # Assertions assert response.status_code == status.HTTP_200_OK response_json = response.json() - assert response_json["message"] == "Login successful" assert response_json["data"]["user"]["email"] == email \ No newline at end of file From f49c00eecc4c54b719a2e62c16c69b26d1abeed0 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 04:58:56 +0200 Subject: [PATCH 07/34] bugfix: updated google auth --- tests/v1/auth/test_google_auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/v1/auth/test_google_auth.py b/tests/v1/auth/test_google_auth.py index 755dde351..7cfa95813 100644 --- a/tests/v1/auth/test_google_auth.py +++ b/tests/v1/auth/test_google_auth.py @@ -67,7 +67,6 @@ def test_google_login_existing_user(mock_user_service, mock_google_oauth_service # Assertions assert response.status_code == status.HTTP_200_OK response_json = response.json() - assert response_json["message"] == "Login successful" assert response_json["data"]["user"]["email"] == email @pytest.mark.usefixtures("mock_db_session", "mock_user_service", "mock_google_oauth_service") From 38636073cbd02789d67331534bf9b7bb34063b1a Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 05:06:29 +0200 Subject: [PATCH 08/34] bugfix: updated google auth --- tests/v1/auth/test_google_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/v1/auth/test_google_auth.py b/tests/v1/auth/test_google_auth.py index 7cfa95813..9021fad69 100644 --- a/tests/v1/auth/test_google_auth.py +++ b/tests/v1/auth/test_google_auth.py @@ -72,7 +72,7 @@ def test_google_login_existing_user(mock_user_service, mock_google_oauth_service @pytest.mark.usefixtures("mock_db_session", "mock_user_service", "mock_google_oauth_service") def test_google_login_new_user(mock_user_service, mock_google_oauth_service, mock_db_session): """Test Google login for a new user.""" - email = "newuser@example.com" + email = "newuser@gmail.com" mock_id_token = "mocked_id_token" # Mock Google OAuth token info response From be6a80b863ac7ae5a1d9d9f91425811daca794d1 Mon Sep 17 00:00:00 2001 From: MikeSoft007 Date: Sat, 24 Aug 2024 05:10:52 +0200 Subject: [PATCH 09/34] bugfix: updated google auth --- tests/v1/test_google_oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/v1/test_google_oauth.py b/tests/v1/test_google_oauth.py index b82c0ec2f..7bb09df24 100644 --- a/tests/v1/test_google_oauth.py +++ b/tests/v1/test_google_oauth.py @@ -75,7 +75,7 @@ def test_google_login( assert response.status_code == 200 response_data = response.json() - assert response_data["message"] == "Successfully authenticated" + assert response_data["message"] == "Login successful" assert response_data["access_token"] == "access_token_example" assert response_data["data"]["user"]["email"] == "test@example.com" assert "refresh_token" in response.cookies From 79e53b6c0a73e33372682ef9bc47ddb853e40353 Mon Sep 17 00:00:00 2001 From: theijhay Date: Sat, 24 Aug 2024 09:12:12 +0100 Subject: [PATCH 10/34] Added search queries functionality --- api/v1/routes/faq.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/api/v1/routes/faq.py b/api/v1/routes/faq.py index a30964c21..fd9a34f66 100644 --- a/api/v1/routes/faq.py +++ b/api/v1/routes/faq.py @@ -1,6 +1,7 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, Query from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session +from typing import Optional from api.db.database import get_db from api.utils.pagination import paginated_response @@ -15,10 +16,19 @@ @faq.get("", response_model=success_response, status_code=200) -async def get_all_faqs(db: Session = Depends(get_db),): - """Endpoint to get all FAQs""" +async def get_all_faqs( + db: Session = Depends(get_db), + keyword: Optional[str] = Query(None, min_length=1) +): + """Endpoint to get all FAQs or search by keyword in both question and answer""" + + query_params = {} + if keyword: + """Search by both question and answer fields""" + query_params["question"] = keyword + query_params["answer"] = keyword - faqs = faq_service.fetch_all(db=db) + faqs = faq_service.fetch_all(db=db, **query_params) return success_response( status_code=200, @@ -27,6 +37,7 @@ async def get_all_faqs(db: Session = Depends(get_db),): ) + @faq.post("", response_model=success_response, status_code=201) async def create_faq( schema: CreateFAQ, From 75aa1443aa451b41715d34df28495d1a23b5f783 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 11:33:16 +0100 Subject: [PATCH 11/34] 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 12/34] 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 c1fe3b9d64276734ab7f8752428ab27f433bed8b Mon Sep 17 00:00:00 2001 From: Oluwanifemi Date: Sat, 24 Aug 2024 12:38:02 +0100 Subject: [PATCH 13/34] feat: implement returning faqs by category --- api/v1/routes/faq.py | 9 ++++----- api/v1/services/faq.py | 23 ++++++++++++++++++++- tests/v1/faq/get_all_faqs_test.py | 33 ++++++++++++++++++------------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/api/v1/routes/faq.py b/api/v1/routes/faq.py index fd9a34f66..ab6573180 100644 --- a/api/v1/routes/faq.py +++ b/api/v1/routes/faq.py @@ -21,23 +21,22 @@ async def get_all_faqs( keyword: Optional[str] = Query(None, min_length=1) ): """Endpoint to get all FAQs or search by keyword in both question and answer""" - + query_params = {} if keyword: - """Search by both question and answer fields""" query_params["question"] = keyword query_params["answer"] = keyword - faqs = faq_service.fetch_all(db=db, **query_params) + grouped_faqs = faq_service.fetch_all_grouped_by_category( + db=db, **query_params) return success_response( status_code=200, message="FAQs retrieved successfully", - data=jsonable_encoder(faqs), + data=jsonable_encoder(grouped_faqs), ) - @faq.post("", response_model=success_response, status_code=201) async def create_faq( schema: CreateFAQ, diff --git a/api/v1/services/faq.py b/api/v1/services/faq.py index 06f19ec38..ed3852055 100644 --- a/api/v1/services/faq.py +++ b/api/v1/services/faq.py @@ -19,6 +19,26 @@ def create(self, db: Session, schema: CreateFAQ): return new_faq + def fetch_all_grouped_by_category(self, db: Session): + """Fetch all FAQs grouped by category""" + query = db.query(FAQ.category, FAQ.question, FAQ.answer) + + if query_params: + for column, value in query_params.items(): + if hasattr(FAQ, column) and value: + query = query.filter( + getattr(FAQ, column).ilike(f"%{value}%")) + faqs = query.order_by(FAQ.category).all() + + grouped_faqs = {} + for faq in faqs: + if faq.category not in grouped_faqs: + grouped_faqs[faq.category] = [] + grouped_faqs[faq.category].append( + {"question": faq.question, "answer": faq.answer}) + + return grouped_faqs + def fetch_all(self, db: Session, **query_params: Optional[Any]): """Fetch all FAQs with option to search using query parameters""" @@ -28,7 +48,8 @@ def fetch_all(self, db: Session, **query_params: Optional[Any]): if query_params: for column, value in query_params.items(): if hasattr(FAQ, column) and value: - query = query.filter(getattr(FAQ, column).ilike(f"%{value}%")) + query = query.filter( + getattr(FAQ, column).ilike(f"%{value}%")) return query.all() diff --git a/tests/v1/faq/get_all_faqs_test.py b/tests/v1/faq/get_all_faqs_test.py index 6f4df5704..2169947f1 100644 --- a/tests/v1/faq/get_all_faqs_test.py +++ b/tests/v1/faq/get_all_faqs_test.py @@ -1,11 +1,10 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from api.db.database import get_db -from api.v1.models.faq import FAQ from api.v1.services.faq import faq_service from main import app @@ -15,6 +14,7 @@ def mock_db_session(): db_session = MagicMock(spec=Session) return db_session + @pytest.fixture def client(mock_db_session): app.dependency_overrides[get_db] = lambda: mock_db_session @@ -23,19 +23,24 @@ def client(mock_db_session): app.dependency_overrides = {} -def test_get_all_faqs(mock_db_session, client): - """Test to verify the pagination response for FAQs.""" - # Mock data - mock_faq_data = [ - FAQ(id=1, question="Question 1", answer="Answer 1"), - FAQ(id=2, question="Question 2", answer="Answer 2"), - FAQ(id=3, question="Question 3", answer="Answer 3"), - ] +def test_get_all_faqs_grouped_by_category(mock_db_session, client): + """Test to verify the response for FAQs grouped by category.""" + + mock_faq_data_grouped = { + "General": [ + {"question": "What is FastAPI?", + "answer": "FastAPI is a modern web framework for Python."}, + {"question": "What is SQLAlchemy?", + "answer": "SQLAlchemy is a SQL toolkit and ORM for Python."} + ], + "Billing": [ + {"question": "How do I update my billing information?", + "answer": "You can update your billing information in the account settings."} + ] + } - app.dependency_overrides[faq_service.fetch_all] = mock_faq_data + with patch.object(faq_service, 'fetch_all_grouped_by_category', return_value=mock_faq_data_grouped): - # Perform the GET request - response = client.get('/api/v1/faqs') + response = client.get('/api/v1/faqs') - # Verify the response assert response.status_code == 200 From c12f3c1c65a5e4534fc900c07ddceae0f31b57b8 Mon Sep 17 00:00:00 2001 From: theijhay Date: Sat, 24 Aug 2024 12:38:25 +0100 Subject: [PATCH 14/34] 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 b03099f6dee367419591954a74536dbdcaa5a037 Mon Sep 17 00:00:00 2001 From: Oluwanifemi Date: Sat, 24 Aug 2024 12:47:19 +0100 Subject: [PATCH 15/34] chore: added query params --- api/v1/services/faq.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/services/faq.py b/api/v1/services/faq.py index ed3852055..161913246 100644 --- a/api/v1/services/faq.py +++ b/api/v1/services/faq.py @@ -19,7 +19,7 @@ def create(self, db: Session, schema: CreateFAQ): return new_faq - def fetch_all_grouped_by_category(self, db: Session): + def fetch_all_grouped_by_category(self, db: Session, **query_params: Optional[Any]): """Fetch all FAQs grouped by category""" query = db.query(FAQ.category, FAQ.question, FAQ.answer) From 9f24c4e950f0e72ab0bfb77e3e2e7e376a8dab71 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 13:14:15 +0100 Subject: [PATCH 16/34] 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 17/34] 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 18/34] 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 19/34] 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 20/34] 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 21/34] 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 22/34] 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 23/34] 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 24/34] 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 25/34] 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 26/34] 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 27/34] 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 28/34] 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 29/34] 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 30/34] 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 31/34] 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 32/34] 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 33/34] 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 34/34] 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