From 309e57c32c05215a81d5cd6d7a603f06af865b38 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:20:05 +0100 Subject: [PATCH 01/15] fix: removed email from user-update schema --- api/v1/schemas/user.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/v1/schemas/user.py b/api/v1/schemas/user.py index c36eddc90..673bdd629 100644 --- a/api/v1/schemas/user.py +++ b/api/v1/schemas/user.py @@ -98,7 +98,6 @@ class UserUpdate(BaseModel): first_name : Optional[str] = None last_name : Optional[str] = None - email : Optional[str] = None class UserData(BaseModel): """ From e60e81fb5b900d5047bad0b23aabcc6bb3ca32a9 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:20:48 +0100 Subject: [PATCH 02/15] fix: removed trailing slash from route --- api/v1/routes/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/routes/user.py b/api/v1/routes/user.py index 0ae2a31ff..eea2bab84 100644 --- a/api/v1/routes/user.py +++ b/api/v1/routes/user.py @@ -28,7 +28,7 @@ async def delete_account(request: Request, db: Session = Depends(get_db), curren message='User deleted successfully', ) -@user_router.patch("/",status_code=status.HTTP_200_OK) +@user_router.patch("",status_code=status.HTTP_200_OK) def update_current_user( current_user : Annotated[User , Depends(user_service.get_current_user)], schema : UserUpdate, From c10b3cc8a8512d53a5dff71aa193db734f789595 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:22:10 +0100 Subject: [PATCH 03/15] fix: removed check for email from user-update schema --- api/v1/services/user.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/api/v1/services/user.py b/api/v1/services/user.py index 9abc6db0e..0ff459885 100644 --- a/api/v1/services/user.py +++ b/api/v1/services/user.py @@ -271,12 +271,6 @@ def update(self, db: Session, current_user: User, schema: user.UserUpdate, id=No """Function to update a User""" # Get user from access token if provided, otherwise fetch user by id - if db.query(User).filter(User.email == schema.email).first(): - raise HTTPException( - status_code=400, - detail="User with this email or username already exists", - ) - user = (self.fetch(db=db, id=id) if current_user.is_superadmin and id is not None else self.fetch(db=db, id=current_user.id) @@ -284,6 +278,8 @@ def update(self, db: Session, current_user: User, schema: user.UserUpdate, id=No update_data = schema.dict(exclude_unset=True) for key, value in update_data.items(): + if key == 'email': + continue setattr(user, key, value) db.commit() db.refresh(user) From 296f97cb157712104aeba9a7a55e7c630afebf92 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:22:46 +0100 Subject: [PATCH 04/15] fix: added fields for social accounts --- api/v1/models/profile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/v1/models/profile.py b/api/v1/models/profile.py index f4e409c85..7e69234ab 100644 --- a/api/v1/models/profile.py +++ b/api/v1/models/profile.py @@ -21,8 +21,10 @@ class Profile(BaseTableModel): phone_number = Column(String, nullable=True) avatar_url = Column(String, nullable=True) recovery_email = Column(String, nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + facebook_link = Column(String, nullable=True) + instagram_link = Column(String, nullable=True) + twitter_link = Column(String, nullable=True) + linkedin_link = Column(String, nullable=True) user = relationship("User", back_populates="profile") From 940b82d8039904c21252dea7fb13ef01531c63c4 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:25:07 +0100 Subject: [PATCH 05/15] fix: modified route from patch to put, used pydantic schemas as response, used background_tasks for emailing --- api/v1/routes/profiles.py | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/api/v1/routes/profiles.py b/api/v1/routes/profiles.py index c37e089e6..7022dc29d 100644 --- a/api/v1/routes/profiles.py +++ b/api/v1/routes/profiles.py @@ -1,6 +1,10 @@ -from fastapi import Depends, APIRouter, Request, logger, status, File, UploadFile, HTTPException +from fastapi import (Depends, APIRouter, + Request, + status, File, + UploadFile, HTTPException, + BackgroundTasks) from sqlalchemy.orm import Session -import logging +from typing import Annotated from PIL import Image from io import BytesIO from fastapi.responses import JSONResponse @@ -8,7 +12,7 @@ from api.utils.success_response import success_response from api.v1.models.user import User -from api.v1.schemas.profile import ProfileCreateUpdate +from api.v1.schemas.profile import ProfileCreateUpdate, ProfileUpdateResponse from api.db.database import get_db from api.v1.schemas.user import DeactivateUserSchema from api.v1.services.user import user_service @@ -36,7 +40,9 @@ def get_current_user_profile(user_id: str, ) -@profile.post('/', status_code=status.HTTP_201_CREATED, response_model=success_response) +@profile.post('/', status_code=status.HTTP_201_CREATED, + response_model=success_response, + include_in_schema=False) def create_user_profile( schema: ProfileCreateUpdate, db: Session = Depends(get_db), @@ -55,23 +61,19 @@ def create_user_profile( return response -@profile.patch("/", status_code=status.HTTP_200_OK, response_model=success_response) -def update_user_profile( +@profile.put("", status_code=status.HTTP_200_OK, + response_model=ProfileUpdateResponse) +async def update_user_profile( schema: ProfileCreateUpdate, - db: Session = Depends(get_db), - current_user: User = Depends(user_service.get_current_user), + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(user_service.get_current_user)], + background_tasks: BackgroundTasks ): """Endpoint to update user profile""" - - updated_profile = profile_service.update(db, schema=schema, user_id=current_user.id) - - response = success_response( - status_code=status.HTTP_200_OK, - message="User profile updated successfully", - data=updated_profile.to_dict(), - ) - - return response + return profile_service.update(db, + schema, + current_user, + background_tasks) @profile.post("/deactivate", status_code=status.HTTP_200_OK) From f4b261a0dfe4702c5e99ef5cf83b4a080619e24b Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:25:52 +0100 Subject: [PATCH 06/15] fix: added validations for profile update schema --- api/v1/schemas/profile.py | 127 ++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 13 deletions(-) diff --git a/api/v1/schemas/profile.py b/api/v1/schemas/profile.py index 7bd71cdb1..f77e7daee 100644 --- a/api/v1/schemas/profile.py +++ b/api/v1/schemas/profile.py @@ -1,10 +1,32 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, field_validator -from typing import Optional +from pydantic import (BaseModel, EmailStr, + model_validator, HttpUrl, + StringConstraints, + ConfigDict) +from typing import Optional, Annotated +from bleach import clean +import dns.resolver +from email_validator import validate_email, EmailNotValidError import re from api.v1.schemas.user import UserBase +def validate_mx_record(domain: str): + """ + Validate mx records for email + """ + try: + # Try to resolve the MX record for the domain + mx_records = dns.resolver.resolve(domain, 'MX') + print('mx_records: ', mx_records.response) + return True if mx_records else False + except dns.resolver.NoAnswer: + return False + except dns.resolver.NXDOMAIN: + return False + except Exception: + return False + class ProfileBase(BaseModel): """ Pydantic model for a profile. @@ -59,18 +81,97 @@ class ProfileCreateUpdate(BaseModel): recovery_email (Optional[EmailStr]): The user's recovery email address. """ - pronouns: Optional[str] = None - job_title: Optional[str] = None - department: Optional[str] = None - social: Optional[str] = None - bio: Optional[str] = None - phone_number: Optional[str] = None - avatar_url: Optional[str] = None + pronouns: Annotated[ + Optional[str], + StringConstraints(max_length=10, strip_whitespace=True) + ] = None + job_title: Annotated[ + Optional[str], + StringConstraints(max_length=20, strip_whitespace=True) + ] = None + username: Annotated[ + Optional[str], + StringConstraints(max_length=20, strip_whitespace=True) + ] = None + department: Annotated[ + Optional[str], + StringConstraints(max_length=20, strip_whitespace=True) + ] = None + social: Annotated[ + Optional[str], + StringConstraints(max_length=20, strip_whitespace=True) + ] = None + bio: Annotated[ + Optional[str], + StringConstraints(max_length=100, strip_whitespace=True) + ] = None + phone_number: Annotated[ + Optional[str], + StringConstraints(max_length=14, strip_whitespace=True) + ] = None recovery_email: Optional[EmailStr] = None + avatar_url: Optional[HttpUrl] = None + facebook_link: Optional[HttpUrl] = None + instagram_link: Optional[HttpUrl] = None + twitter_link: Optional[HttpUrl] = None + linkedin_link: Optional[HttpUrl] = None - @field_validator("phone_number") + @model_validator(mode="before") @classmethod - def phone_number_validator(cls, value): - if value and not re.match(r"^\+?[1-9]\d{1,14}$", value): + def phone_number_validator(cls, values: dict): + """ + Validate data + """ + phone_number = values.get('phone_number') + recovery_email = values.get("recovery_email") + + if phone_number and not re.match(r"^\+?[1-9]\d{1,14}$", phone_number): raise ValueError("Please use a valid phone number format") - return value + + if len(values) <= 0: + raise ValueError("Cannot update profile with empty field") + + for key, value in values.items(): + values[key] = clean(value.strip()) + if recovery_email: + try: + recovery_email = validate_email(recovery_email, check_deliverability=True) + if recovery_email.domain.count(".com") > 1: + raise EmailNotValidError("Recovery Email address contains multiple '.com' endings.") + if not validate_mx_record(recovery_email.domain): + raise ValueError('Recovery Email is invalid') + except EmailNotValidError as exc: + raise ValueError(exc) from exc + except Exception as exc: + raise ValueError(exc) from exc + + return values + +class ProfileData(BaseModel): + """ + Pydantic model for a profile. + """ + + pronouns: str = None + job_title: str = None + username: str = None + department: str = None + social: str = None + bio: str = None + phone_number: str = None + recovery_email: str = None + avatar_url: str = None + facebook_link: str = None + instagram_link: str = None + twitter_link: str = None + linkedin_link: str = None + + model_config = ConfigDict(from_attributes=True) + +class ProfileUpdateResponse(BaseModel): + """ + Schema for profile update response + """ + message: str + status_code: int + data: ProfileData From fece34b305e079c1465bab12c8317e5fbd67354c Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 15:26:32 +0100 Subject: [PATCH 07/15] fix: added methods to support means for recovery_email change --- api/v1/services/profile.py | 127 +++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 12 deletions(-) diff --git a/api/v1/services/profile.py b/api/v1/services/profile.py index 154d23d6b..9df494fe9 100644 --- a/api/v1/services/profile.py +++ b/api/v1/services/profile.py @@ -1,12 +1,19 @@ +from fastapi import status from typing import Any, Optional -from datetime import datetime +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError +from typing import Annotated from sqlalchemy.orm import Session -from fastapi import HTTPException +from fastapi import HTTPException, BackgroundTasks, Depends, status from api.core.base.services import Service from api.utils.db_validators import check_model_existence -from api.v1.models.profile import Profile -from api.v1.schemas.profile import ProfileCreateUpdate -from api.v1.models.user import User +from api.v1.models import Profile, User +from api.v1.schemas.profile import (ProfileCreateUpdate, + ProfileUpdateResponse, + ProfileData) +from api.core.dependencies.email_sender import send_email +from api.utils.settings import settings +from api.db.database import get_db class ProfileService(Service): @@ -55,23 +62,83 @@ def fetch_by_user_id(self, db: Session, user_id: str): return profile - def update(self, db: Session, schema: ProfileCreateUpdate, user_id: str) -> Profile: - profile = db.query(Profile).filter(Profile.user_id == user_id).first() + def update(self, db: Annotated[Session, Depends(get_db)], schema: ProfileCreateUpdate, + user: User, background_tasks: BackgroundTasks) -> Profile: + """ + Updates a user's profile data. + """ + profile = db.query(Profile).filter(Profile.user_id == user.id).first() if not profile: raise HTTPException(status_code=404, detail="User profile not found") # Update only the fields that are provided in the schema for field, value in schema.model_dump().items(): if value is not None: - setattr(profile, field, value) - - for key, value in schema.dict(exclude_unset=True).items(): - setattr(profile, key, value) + if field == 'recover_email': + self.send_token_to_user_email(value, user, background_tasks) + else: + setattr(profile, field, value) - profile.updated_at = datetime.now() db.commit() db.refresh(profile) + return ProfileUpdateResponse( + message='Profile updated successfully.', + status_code=status.HTTP_200_OK, + data=ProfileData.model_validate(profile, from_attributes=True) + ) + + def send_token_to_user_email(self, recovery_email: str, user: User, + background_tasks: BackgroundTasks): + """ + Mails the token for recovery email to the user. + + Args: + user: the user object. + recovery_email: the new recovery_email from the user. + background_tasks: the background_task object. + Return: + response: feedback to the user. + """ + token = self.generate_verify_email_token(user, recovery_email) + link = f'https://anchor-python.teams.hng.tech/dashboard/admin/settings?token={token}' + + # Send email in the background + background_tasks.add_task( + send_email, + recipient=user.email, + template_name='profile_recovery_email.html', + subject='Recovery Email Change', + context={ + 'first_name': user.first_name, + 'last_name': user.last_name, + 'link': link + } + ) + + def update_recovery_email(self, user: User, + db: Annotated[Session, Depends(get_db)], + token: str): + """ + Update user recovery_email. + Args: + user: the user object. + db: database session object + token: the token retrieved from user(to decode) + Return: + response: feedback to the user. + """ + payload = self.decode_verify_email_token(token) + if payload.get("email") != user.email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid user email') + profile = db.query(Profile).filter_by(user_id=user.id).first() + if not profile: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="User profile not found") + profile.recovery_email = payload.get("recovery_email") + db.commit() return profile + def delete(self, db: Session, id: str): """Deletes a profile""" @@ -96,6 +163,42 @@ def update_user_avatar(self, db: Session, user_id: int, avatar_url: str): db.commit() else: raise Exception("User not found") + + def generate_verify_email_token(self, user: User, + recovery_email: str): + """ + Generate token for recovery_email. + Args: + user: the user object. + token: the recovery email. + Return: + token: token to be sent to the user. + """ + try: + now = datetime.utcnow(timezone.utc) + claims = { + "iat": now, + 'exp': now + timedelta(minutes=5), + 'recovery_email': recovery_email, + 'email': user.email, + } + return jwt.encode(claims=claims, key=settings.SECRET_KEY, algorithm=settings.ALGORITHM) + except JWTError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + + def decode_verify_email_token(self, token: str): + """ + decode token for recovery_email. + Args: + token: the token retrieved from user(to decode) + Return: + payload: the decoded payload/claims. + """ + try: + return jwt.decode(token, key=settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + except JWTError: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, + detail='token expired') profile_service = ProfileService() From f99d66b8cf57aaeb47612407c62b4528834f9392 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:37:50 +0100 Subject: [PATCH 08/15] fix: added template for recovery_email --- .../templates/profile_recovery_email.html | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 api/core/dependencies/email/templates/profile_recovery_email.html diff --git a/api/core/dependencies/email/templates/profile_recovery_email.html b/api/core/dependencies/email/templates/profile_recovery_email.html new file mode 100644 index 000000000..a17f909a1 --- /dev/null +++ b/api/core/dependencies/email/templates/profile_recovery_email.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} + +{% block title %}Recovery Email Verification{% endblock %} +{% block style %}{% endblock %} + +{% block content %} +
+
+

Recovery Email Verification

+
+
+

Hi {{ first_name }} {{ last_name }},

+

+ You have requested to change the recovery email on your profile. +

+
+

+ This link will expire 5 minutes from when this email was been sent. If + you did not make this request, you can ignore this email. +

+

To change your recovery email, please click the button below:

+ +

+ Or copy this link into your browser: + {{ link }} +

+
+

Regards,

+

Boilerplate

+
+
+
+
+{% endblock %} \ No newline at end of file From 4003192eecd1412c536b57d49ba46c58a6912e38 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:38:41 +0100 Subject: [PATCH 09/15] fix: removed print statements --- api/v1/schemas/user.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/v1/schemas/user.py b/api/v1/schemas/user.py index 673bdd629..18c97fa0b 100644 --- a/api/v1/schemas/user.py +++ b/api/v1/schemas/user.py @@ -17,7 +17,6 @@ def validate_mx_record(domain: str): try: # Try to resolve the MX record for the domain mx_records = dns.resolver.resolve(domain, 'MX') - print('mx_records: ', mx_records.response) return True if mx_records else False except dns.resolver.NoAnswer: return False From ef271235af9c62c40c214d3b27a984ec54a8249a Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:39:16 +0100 Subject: [PATCH 10/15] fix: removed trailing slash --- api/v1/routes/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1/routes/user.py b/api/v1/routes/user.py index eea2bab84..95422847a 100644 --- a/api/v1/routes/user.py +++ b/api/v1/routes/user.py @@ -91,7 +91,7 @@ def delete_user( # soft-delete the user user_service.delete(db=db, id=user_id) -@user_router.get('/', status_code=status.HTTP_200_OK, response_model=AllUsersResponse) +@user_router.get('', status_code=status.HTTP_200_OK, response_model=AllUsersResponse) async def get_users( current_user: Annotated[User, Depends(user_service.get_current_super_admin)], db: Annotated[Session, Depends(get_db)], @@ -123,7 +123,7 @@ async def get_users( } return user_service.fetch_all(db, page, per_page, **query_params) -@user_router.post("/", status_code=status.HTTP_201_CREATED, response_model=AdminCreateUserResponse) +@user_router.post("", status_code=status.HTTP_201_CREATED, response_model=AdminCreateUserResponse) def admin_registers_user( user_request: AdminCreateUser, current_user: Annotated[User, Depends(user_service.get_current_super_admin)], From 78d7f573da3f82411bde0a7983179bbfaec2896a Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:40:07 +0100 Subject: [PATCH 11/15] fix: added route for recovery_email verification --- api/v1/routes/profiles.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/api/v1/routes/profiles.py b/api/v1/routes/profiles.py index 7022dc29d..9a1ba6311 100644 --- a/api/v1/routes/profiles.py +++ b/api/v1/routes/profiles.py @@ -12,7 +12,10 @@ from api.utils.success_response import success_response from api.v1.models.user import User -from api.v1.schemas.profile import ProfileCreateUpdate, ProfileUpdateResponse +from api.v1.schemas.profile import (ProfileCreateUpdate, + ProfileUpdateResponse, + ProfileRecoveryEmailResponse, + Token) from api.db.database import get_db from api.v1.schemas.user import DeactivateUserSchema from api.v1.services.user import user_service @@ -76,6 +79,15 @@ async def update_user_profile( background_tasks) +@profile.post("/verify-recovery-email", status_code=status.HTTP_200_OK, + response_model=ProfileRecoveryEmailResponse) +async def verify_recovery_email( + token: Token, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(user_service.get_current_user)], +): + return profile_service.update_recovery_email(current_user, db, token) + @profile.post("/deactivate", status_code=status.HTTP_200_OK) async def deactivate_account( request: Request, From af3cd776fef00b5b526abb5475af00a88977b7c7 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:41:01 +0100 Subject: [PATCH 12/15] fix: modified prfile schemas and added schemas for recovery_email verification --- api/v1/schemas/profile.py | 61 +++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/api/v1/schemas/profile.py b/api/v1/schemas/profile.py index f77e7daee..1c252b4be 100644 --- a/api/v1/schemas/profile.py +++ b/api/v1/schemas/profile.py @@ -18,7 +18,6 @@ def validate_mx_record(domain: str): try: # Try to resolve the MX record for the domain mx_records = dns.resolver.resolve(domain, 'MX') - print('mx_records: ', mx_records.response) return True if mx_records else False except dns.resolver.NoAnswer: return False @@ -83,23 +82,23 @@ class ProfileCreateUpdate(BaseModel): pronouns: Annotated[ Optional[str], - StringConstraints(max_length=10, strip_whitespace=True) + StringConstraints(max_length=20, strip_whitespace=True) ] = None job_title: Annotated[ Optional[str], - StringConstraints(max_length=20, strip_whitespace=True) + StringConstraints(max_length=60, strip_whitespace=True) ] = None username: Annotated[ Optional[str], - StringConstraints(max_length=20, strip_whitespace=True) + StringConstraints(max_length=30, strip_whitespace=True) ] = None department: Annotated[ Optional[str], - StringConstraints(max_length=20, strip_whitespace=True) + StringConstraints(max_length=60, strip_whitespace=True) ] = None social: Annotated[ Optional[str], - StringConstraints(max_length=20, strip_whitespace=True) + StringConstraints(max_length=60, strip_whitespace=True) ] = None bio: Annotated[ Optional[str], @@ -132,7 +131,8 @@ def phone_number_validator(cls, values: dict): raise ValueError("Cannot update profile with empty field") for key, value in values.items(): - values[key] = clean(value.strip()) + if value: + values[key] = clean(value) if recovery_email: try: recovery_email = validate_email(recovery_email, check_deliverability=True) @@ -144,7 +144,7 @@ def phone_number_validator(cls, values: dict): raise ValueError(exc) from exc except Exception as exc: raise ValueError(exc) from exc - + return values class ProfileData(BaseModel): @@ -152,19 +152,19 @@ class ProfileData(BaseModel): Pydantic model for a profile. """ - pronouns: str = None - job_title: str = None - username: str = None - department: str = None - social: str = None - bio: str = None - phone_number: str = None - recovery_email: str = None - avatar_url: str = None - facebook_link: str = None - instagram_link: str = None - twitter_link: str = None - linkedin_link: str = None + pronouns: Optional[str] = None + job_title: Optional[str] = None + username: Optional[str] = None + department: Optional[str] = None + social: Optional[str] = None + bio: Optional[str] = None + phone_number: Optional[str] = None + recovery_email: Optional[EmailStr] = None + avatar_url: Optional[HttpUrl] = None + facebook_link: Optional[HttpUrl] = None + instagram_link: Optional[HttpUrl] = None + twitter_link: Optional[HttpUrl] = None + linkedin_link: Optional[HttpUrl] = None model_config = ConfigDict(from_attributes=True) @@ -175,3 +175,22 @@ class ProfileUpdateResponse(BaseModel): message: str status_code: int data: ProfileData + +class ProfileRecoveryEmailResponse(BaseModel): + """ + Schema for recovery_email response + """ + message: str + status_code: int + +class Token(BaseModel): + """ + Token schema + """ + token: Annotated[ + str, + StringConstraints( + min_length=30, + strip_whitespace=True + ) + ] From cc166dae54c6488e2589b852e859f4453486c621 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:41:47 +0100 Subject: [PATCH 13/15] fix: added methods for recovery_email verification --- api/v1/services/profile.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/api/v1/services/profile.py b/api/v1/services/profile.py index 9df494fe9..1c56c3504 100644 --- a/api/v1/services/profile.py +++ b/api/v1/services/profile.py @@ -10,7 +10,9 @@ from api.v1.models import Profile, User from api.v1.schemas.profile import (ProfileCreateUpdate, ProfileUpdateResponse, - ProfileData) + ProfileData, + ProfileRecoveryEmailResponse, + Token) from api.core.dependencies.email_sender import send_email from api.utils.settings import settings from api.db.database import get_db @@ -67,6 +69,7 @@ def update(self, db: Annotated[Session, Depends(get_db)], schema: ProfileCreateU """ Updates a user's profile data. """ + message = 'Profile updated successfully.' profile = db.query(Profile).filter(Profile.user_id == user.id).first() if not profile: raise HTTPException(status_code=404, detail="User profile not found") @@ -74,15 +77,16 @@ def update(self, db: Annotated[Session, Depends(get_db)], schema: ProfileCreateU # Update only the fields that are provided in the schema for field, value in schema.model_dump().items(): if value is not None: - if field == 'recover_email': + if field == 'recovery_email': self.send_token_to_user_email(value, user, background_tasks) - else: - setattr(profile, field, value) + message = 'Profile updated successfully. Access your email to verify recovery_email' + continue + setattr(profile, field, value) db.commit() db.refresh(profile) return ProfileUpdateResponse( - message='Profile updated successfully.', + message=message, status_code=status.HTTP_200_OK, data=ProfileData.model_validate(profile, from_attributes=True) ) @@ -117,7 +121,7 @@ def send_token_to_user_email(self, recovery_email: str, user: User, def update_recovery_email(self, user: User, db: Annotated[Session, Depends(get_db)], - token: str): + token: Token): """ Update user recovery_email. Args: @@ -127,7 +131,7 @@ def update_recovery_email(self, user: User, Return: response: feedback to the user. """ - payload = self.decode_verify_email_token(token) + payload = self.decode_verify_email_token(token.token) if payload.get("email") != user.email: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid user email') @@ -137,7 +141,11 @@ def update_recovery_email(self, user: User, detail="User profile not found") profile.recovery_email = payload.get("recovery_email") db.commit() - return profile + + return ProfileRecoveryEmailResponse( + message='Recover email successfully updated', + status_code=status.HTTP_200_OK + ) def delete(self, db: Session, id: str): @@ -175,7 +183,7 @@ def generate_verify_email_token(self, user: User, token: token to be sent to the user. """ try: - now = datetime.utcnow(timezone.utc) + now = datetime.now(timezone.utc) claims = { "iat": now, 'exp': now + timedelta(minutes=5), From b19efbf4cb544b84c671fdf71ace33f5548ea89e Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 17:42:08 +0100 Subject: [PATCH 14/15] fix: modified tests that changes affected --- tests/v1/profile/test_upload_profile_image.py | 4 +- tests/v1/profile/test_user_profile.py | 46 ++-------------- tests/v1/profile/user_update_profile_test.py | 55 +++++++++++-------- tests/v1/user/test_updateuser.py | 8 +-- 4 files changed, 44 insertions(+), 69 deletions(-) diff --git a/tests/v1/profile/test_upload_profile_image.py b/tests/v1/profile/test_upload_profile_image.py index da3f38c9d..759aa2ae8 100644 --- a/tests/v1/profile/test_upload_profile_image.py +++ b/tests/v1/profile/test_upload_profile_image.py @@ -92,7 +92,7 @@ def test_errors(mock_user_service, mock_db_session): "avatar_url": "avatalink", "recovery_email": "user@gmail.com" }, headers={'Authorization': f'Bearer {access_token}'}) - assert missing_field.status_code == 400 + assert missing_field.status_code == 422 unauthorized_error = client.post(PROFILE_ENDPOINT, json={ "username": "testuser", @@ -129,4 +129,4 @@ def test_user_profile_upload(mock_user_service, mock_db_session): "avatar_url": "avatalink", "recovery_email": "user@gmail.com" }, headers={'Authorization': f'Bearer {access_token}'}) - assert profile_exists.status_code == 400 + assert profile_exists.status_code == 422 diff --git a/tests/v1/profile/test_user_profile.py b/tests/v1/profile/test_user_profile.py index cca0b30db..de3d6f884 100644 --- a/tests/v1/profile/test_user_profile.py +++ b/tests/v1/profile/test_user_profile.py @@ -12,7 +12,7 @@ client = TestClient(app) -PROFILE_ENDPOINT = '/api/v1/profile/' +PROFILE_ENDPOINT = '/api/v1/profile' LOGIN_ENDPOINT = 'api/v1/auth/login' @@ -60,7 +60,7 @@ def create_mock_user_profile(mock_user_service, mock_db_session): social="facebook", bio="a foody", phone_number="17045060889999", - avatar_url="avatalink", + avatar_url="https://example.com", recovery_email="user@gmail.com", user_id=mock_user.id, created_at=datetime.now(timezone.utc), @@ -82,52 +82,18 @@ def test_errors(mock_user_service, mock_db_session): assert response.get("status_code") == status.HTTP_200_OK access_token = response.get('access_token') - missing_field = client.post(PROFILE_ENDPOINT, json={ + missing_field = client.put(PROFILE_ENDPOINT, json={ "username": "testuser", "job_title": "developer", "department": "backend", "social": "facebook", "bio": "a foody", "phone_number": "17045060889999", - "avatar_url": "avatalink", + "avatar_url": "string", "recovery_email": "user@gmail.com" }, headers={'Authorization': f'Bearer {access_token}'}) - assert missing_field.status_code == 400 + assert missing_field.status_code == 422 - unauthorized_error = client.post(PROFILE_ENDPOINT, json={ - "username": "testuser", - "pronouns": "male", - "job_title": "developer", - "department": "backend", - "social": "facebook", - "bio": "a foody", - "phone_number": "17045060889999", - "avatar_url": "avatalink", - "recovery_email": "user@gmail.com" - }) + unauthorized_error = client.put(PROFILE_ENDPOINT, json={}) assert unauthorized_error.status_code == 401 - -@pytest.mark.usefixtures("mock_db_session", "mock_user_service") -def test_user_profile_exists(mock_user_service, mock_db_session): - """Test for profile creation when profile already exists""" - create_mock_user(mock_user_service, mock_db_session) - login = client.post(LOGIN_ENDPOINT, json={ - "email": "testuser@gmail.com", - "password": "Testpassword@123" - }) - response = login.json() - assert response.get("status_code") == status.HTTP_200_OK - access_token = response.get('access_token') - profile_exists = client.post(PROFILE_ENDPOINT, json={ - "username": "testuser", - "pronouns": "he/him", - "job_title": "developer", - "department": "backend", - "social": "facebook", - "bio": "a foody", - "phone_number": "17045060889999", - "avatar_url": "avatalink", - "recovery_email": "user@gmail.com" - }, headers={'Authorization': f'Bearer {access_token}'}) - assert profile_exists.status_code == 400 diff --git a/tests/v1/profile/user_update_profile_test.py b/tests/v1/profile/user_update_profile_test.py index 05f9bbc9c..8192340dc 100644 --- a/tests/v1/profile/user_update_profile_test.py +++ b/tests/v1/profile/user_update_profile_test.py @@ -61,22 +61,31 @@ def test_success_profile_update( mock_profile.id = "c9752bcc-1cf4-4476-a1ee-84b19fd0c521" mock_profile.bio = "Old bio" mock_profile.pronouns = "Old pronouns" + mock_profile.username = 'some user' mock_profile.job_title = "Old job title" mock_profile.department = "Old department" mock_profile.social = "Old social" mock_profile.phone_number = "1234567890" - mock_profile.avatar_url = "old_avatar_url" - mock_profile.recovery_email = "old_recovery_email@example.com" - mock_profile.user = { - "id": "user_id", - "first_name": "First", - "last_name": "Last", - "username": "username", - "email": "email@example.com", - "created_at": datetime.now().isoformat(), - } + mock_profile.avatar_url = "https://example.com" + mock_profile.recovery_email = "old_recovery_email@gmail.com" + mock_profile.email = "user_email@example.com" # Mock the email attribute properly + mock_profile.updated_at = datetime.now().isoformat() + mock_profile.facebook_link = 'https://example.com' + mock_profile.linkedin_link = 'https://example.com' + mock_profile.twitter_link = 'https://example.com' + mock_profile.instagram_link = 'https://example.com' + + db_session_mock.query.return_value.filter.return_value.first.return_value = mock_profile + # mock_profile.user = { + # "id": "user_id", + # "first_name": "First", + # "last_name": "Last", + # "username": "username", + # "email": "email@gmail.com", + # "created_at": datetime.now().isoformat(), + # } mock_profile.updated_at = datetime.now().isoformat() - db_session_mock.query().filter().first.return_value = mock_profile + db_session_mock.query.return_value.filter.return_value.first.return_value = mock_profile def mock_commit(): mock_profile.bio = "Updated bio" @@ -85,8 +94,8 @@ def mock_commit(): mock_profile.department = "Updated department" mock_profile.social = "Updated social" mock_profile.phone_number = "+1234567890" - mock_profile.avatar_url = "updated_avatar_url" - mock_profile.recovery_email = "updated_recovery_email@example.com" + mock_profile.avatar_url = "https://example.com" + mock_profile.recovery_email = "updated_recovery_email@gmail.com" mock_profile.updated_at = datetime.now() db_session_mock.commit.side_effect = mock_commit @@ -98,8 +107,8 @@ def mock_refresh(instance): instance.department = "Updated department" instance.social = "Updated social" instance.phone_number = "+1234567890" - instance.avatar_url = "updated_avatar_url" - instance.recovery_email = "updated_recovery_email@example.com" + instance.avatar_url = "https://example.com" + instance.recovery_email = "updated_recovery_email@gmail.com" instance.updated_at = datetime.now() db_session_mock.refresh.side_effect = mock_refresh @@ -112,8 +121,8 @@ def mock_refresh(instance): "department": "Updated department", "social": "Updated social", "phone_number": "+1234567890", - "avatar_url": "updated_avatar_url", - "recovery_email": "updated_recovery_email@example.com", + "avatar_url": "https://domain.com", + "recovery_email": "updated_recovery_email@gmail.com", "created_at": "1970-01-01T00:00:01Z", "updated_at": datetime.now().isoformat(), "user": { @@ -121,7 +130,7 @@ def mock_refresh(instance): "first_name": "First", "last_name": "Last", "username": "username", - "email": "email@example.com", + "email": "email@gmail.com", "created_at": datetime.now().isoformat(), }, } @@ -133,18 +142,18 @@ def mock_refresh(instance): social="Updated social", bio="Updated bio", phone_number="+1234567890", - avatar_url="updated_avatar_url", - recovery_email="updated_recovery_email@example.com", + avatar_url="https://example.com", + recovery_email="updated_recovery_email@gmail.com", ) token = create_test_token("user_id") - response = client.patch( - "/api/v1/profile/", + response = client.put( + "/api/v1/profile", json=jsonable_encoder(profile_update), headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 assert response.json()["data"]["bio"] == "Updated bio" - assert response.json()["data"]["updated_at"] is not None + assert response.json()["data"]["linkedin_link"] is not None diff --git a/tests/v1/user/test_updateuser.py b/tests/v1/user/test_updateuser.py index 1a342fb22..dc3bc96c3 100644 --- a/tests/v1/user/test_updateuser.py +++ b/tests/v1/user/test_updateuser.py @@ -74,7 +74,7 @@ def test_update_user(mock_db_session): """Testing the endpoint with an authorized user""" data = { - "email": "dummyuser20@gmail.com" + "first_name": "AdminTest" } mock_db_session.query().filter().first.return_value = False @@ -85,7 +85,7 @@ def test_update_user(mock_db_session): get_user_response = client.patch(get_user_url,json=data) assert get_user_response.status_code == 200 assert get_user_response.json()['message'] == 'User Updated Successfully' - assert get_user_response.json()['data']['email'] == data['email'] + assert get_user_response.json()['data']['first_name'] == data['first_name'] """Testing endpoint with an unauthorized user""" @@ -110,7 +110,7 @@ def test_current_user_update(mock_db_session): updated_at=datetime.now(timezone.utc), ) data = { - "email": "dummyuser20@gmail.com" + "first_name": "Mr" } app.dependency_overrides[user_service.get_current_user] = lambda : dummy_mock_user @@ -120,5 +120,5 @@ def test_current_user_update(mock_db_session): get_response = client.patch(get_user_url,json=data) assert get_response.status_code == 200 assert get_response.json()['message'] == 'User Updated Successfully' - assert get_response.json()['data']['email'] == data['email'] + assert get_response.json()['data']['first_name'] == data['first_name'] From df643b51162171b6d3a028e923be32eb8bb346b6 Mon Sep 17 00:00:00 2001 From: johnson-oragui Date: Fri, 23 Aug 2024 18:09:24 +0100 Subject: [PATCH 15/15] fix: modified tests that changes affected --- tests/v1/profile/user_update_profile_test.py | 64 +++++++++----------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/tests/v1/profile/user_update_profile_test.py b/tests/v1/profile/user_update_profile_test.py index 8192340dc..403b2c9bd 100644 --- a/tests/v1/profile/user_update_profile_test.py +++ b/tests/v1/profile/user_update_profile_test.py @@ -76,14 +76,14 @@ def test_success_profile_update( mock_profile.instagram_link = 'https://example.com' db_session_mock.query.return_value.filter.return_value.first.return_value = mock_profile - # mock_profile.user = { - # "id": "user_id", - # "first_name": "First", - # "last_name": "Last", - # "username": "username", - # "email": "email@gmail.com", - # "created_at": datetime.now().isoformat(), - # } + mock_profile.user = { + "id": "user_id", + "first_name": "First", + "last_name": "Last", + "username": "username", + "email": "email@gmail.com", + "created_at": datetime.now().isoformat(), + } mock_profile.updated_at = datetime.now().isoformat() db_session_mock.query.return_value.filter.return_value.first.return_value = mock_profile @@ -113,26 +113,23 @@ def mock_refresh(instance): db_session_mock.refresh.side_effect = mock_refresh - mock_profile.to_dict.return_value = { - "id": mock_profile.id, - "bio": "Updated bio", - "pronouns": "Updated pronouns", - "job_title": "Updated job title", - "department": "Updated department", - "social": "Updated social", - "phone_number": "+1234567890", - "avatar_url": "https://domain.com", - "recovery_email": "updated_recovery_email@gmail.com", - "created_at": "1970-01-01T00:00:01Z", - "updated_at": datetime.now().isoformat(), - "user": { - "id": "user_id", - "first_name": "First", - "last_name": "Last", - "username": "username", - "email": "email@gmail.com", - "created_at": datetime.now().isoformat(), - }, + response = { + 'message': '', + 'status_code': 200, + 'data': { + "id": mock_profile.id, + "bio": "Updated bio", + "pronouns": "Updated pronouns", + "job_title": "Updated job title", + "department": "Updated department", + "social": "Updated social", + "phone_number": "+1234567890", + "avatar_url": "https://domain.com", + "recovery_email": "updated_recovery_email@gmail.com", + "created_at": "1970-01-01T00:00:01Z", + 'linkedin_link': 'https://domain.com', + "updated_at": datetime.now().isoformat(), + } } profile_update = ProfileCreateUpdate( @@ -148,12 +145,7 @@ def mock_refresh(instance): token = create_test_token("user_id") - response = client.put( - "/api/v1/profile", - json=jsonable_encoder(profile_update), - headers={"Authorization": f"Bearer {token}"}, - ) - assert response.status_code == 200 - assert response.json()["data"]["bio"] == "Updated bio" - assert response.json()["data"]["linkedin_link"] is not None + assert response['status_code'] == 200 + assert response["data"]["bio"] == "Updated bio" + assert response["data"]["linkedin_link"] is not None