diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..26d33521a
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/hng_boilerplate_python_fastapi_web.iml b/.idea/hng_boilerplate_python_fastapi_web.iml
new file mode 100644
index 000000000..7a6134d11
--- /dev/null
+++ b/.idea/hng_boilerplate_python_fastapi_web.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000..33c6cd1ee
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 000000000..105ce2da2
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 000000000..3f0fd9bf8
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 000000000..6c4a82298
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..35eb1ddfb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/utils/auth.py b/api/utils/auth.py
index 1885292db..4a77211a7 100644
--- a/api/utils/auth.py
+++ b/api/utils/auth.py
@@ -12,6 +12,8 @@
from api.v1.schemas.token import TokenData
from api.db.database import get_db
from .config import SECRET_KEY, ALGORITHM
+from urllib.parse import urlencode
+import uuid
# Initialize OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
@@ -27,6 +29,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def get_user(db, username: str):
return db.query(User).filter(User.username == username).first()
+
def authenticate_user(db, username: str, password: str):
user = get_user(db, username)
if not user:
@@ -43,4 +46,21 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
- return encoded_jwt
\ No newline at end of file
+ return encoded_jwt
+
+
+def get_user_by_email(db: Session, email: str):
+ return db.query(User).filter(User.email == email).first()
+
+
+def generate_password_reset_token(user_id: uuid.UUID) -> str:
+ expires_delta = timedelta(minutes=30)
+ data = {"sub": user_id}
+ return create_access_token(data=data, expires_delta=expires_delta)
+
+
+def generate_reset_password_url(user_id: uuid.UUID, token: str) -> str:
+ base_url = f"http://localhost:7001"
+ path = "/reset-password"
+ query_params = urlencode({"token": token, "user_id": user_id})
+ return f"{base_url}{path}?{query_params}"
diff --git a/api/utils/settings.py b/api/utils/settings.py
index afc23dc65..93201437c 100644
--- a/api/utils/settings.py
+++ b/api/utils/settings.py
@@ -28,6 +28,8 @@ class Settings(BaseSettings):
MAIL_FROM: str = config('MAIL_FROM')
MAIL_PORT: int = config('MAIL_PORT')
MAIL_SERVER: str = config('MAIL_SERVER')
+ MAIL_STARTTLS: bool = config('MAIL_TLS')
+ MAIL_SSL_TLS: bool = config('MAIL_SSL')
settings = Settings()
\ No newline at end of file
diff --git a/api/v1/models/user.py b/api/v1/models/user.py
index 916f1ed07..4727f8126 100644
--- a/api/v1/models/user.py
+++ b/api/v1/models/user.py
@@ -21,11 +21,13 @@
from api.v1.models.base import Base, user_organization_association, user_role_association
from api.v1.models.base_model import BaseModel
from sqlalchemy.dialects.postgresql import UUID
+import uuid
class User(BaseModel, Base):
__tablename__ = 'users'
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
password = Column(String(255), nullable=False)
diff --git a/api/v1/routes/auth.py b/api/v1/routes/auth.py
index 3a385c6a7..9276abc06 100644
--- a/api/v1/routes/auth.py
+++ b/api/v1/routes/auth.py
@@ -18,7 +18,14 @@
from api.v1.schemas.token import Token, LoginRequest
from api.v1.schemas.auth import UserBase, SuccessResponse, SuccessResponseData, UserCreate
from api.db.database import get_db
-from api.utils.auth import authenticate_user, create_access_token,hash_password,get_user
+from api.utils.auth import (
+ authenticate_user,
+ create_access_token,
+ hash_password,get_user,
+ generate_password_reset_token,
+ generate_reset_password_url,
+ get_user_by_email
+)
from api.utils.dependencies import get_current_admin, get_current_user
@@ -26,12 +33,20 @@
from api.v1.models.product import Product
+from fastapi import BackgroundTasks
+from fastapi_mail import FastMail, MessageSchema
+
+from fastapi import BackgroundTasks
+from fastapi_mail import ConnectionConfig
+from pydantic import BaseModel, EmailStr
+from api.utils.settings import settings, BASE_DIR
db = next(get_db())
auth = APIRouter(prefix="/auth", tags=["auth"])
+# This is in the .evn file already though
ACCESS_TOKEN_EXPIRE_MINUTES = 30
@@ -111,3 +126,51 @@ def register_user(user: UserCreate, db: Session = Depends(get_db)):
def read_admin_data(current_admin: Annotated[User, Depends(get_current_admin)]):
return {"message": "Hello, admin!"}
+
+conf = ConnectionConfig(
+ MAIL_USERNAME=settings.MAIL_USERNAME,
+ MAIL_PASSWORD=settings.MAIL_PASSWORD,
+ MAIL_FROM=settings.MAIL_FROM,
+ MAIL_PORT=settings.MAIL_PORT,
+ MAIL_SERVER=settings.MAIL_SERVER,
+ MAIL_STARTTLS=settings.MAIL_STARTTLS,
+ MAIL_SSL_TLS=settings.MAIL_SSL_TLS
+)
+
+
+class EmailRequest(BaseModel):
+ email: EmailStr
+
+
+@auth.post("/password-reset-email/", status_code=status.HTTP_200_OK)
+async def send_email(background_tasks: BackgroundTasks, email: EmailRequest, db: Session = Depends(get_db)):
+ """
+ Getting the user from the database, the email in the db since the email schema in the db is unique, it picks the 1st
+ """
+
+ user = get_user_by_email(db, email.email)
+ if not user:
+ raise HTTPException(status_code=404, detail="We don't have user with the provided email in our database.")
+ # Generate password reset token
+ password_reset_token = generate_password_reset_token(user_id=str(user.id))
+ reset_password_url = generate_reset_password_url(user_id=str(user.id), token=password_reset_token)
+
+ email_body = (f"Dear {user.username}!\nYou requested for email reset on our site.\n"
+ f"To reset your password, click the following link: {reset_password_url}")
+
+ message: MessageSchema = MessageSchema(
+ subject="Reset Password",
+ recipients=[email.email],
+ body=email_body,
+ subtype="plain"
+ )
+ fm = FastMail(conf)
+
+ try:
+ background_tasks.add_task(fm.send_message, message)
+ return {
+ "message": "Password reset email sent successfully.",
+ "reset_link": reset_password_url
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Error sending email: {e}")
diff --git a/test.db b/test.db
new file mode 100644
index 000000000..13823a3d1
Binary files /dev/null and b/test.db differ
diff --git a/tests/v1/reset_password_test.py b/tests/v1/reset_password_test.py
new file mode 100644
index 000000000..0ceb5d763
--- /dev/null
+++ b/tests/v1/reset_password_test.py
@@ -0,0 +1,111 @@
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from main import app
+from api.db.database import get_db
+from api.db.database import Base
+from unittest.mock import patch, AsyncMock
+from fastapi_mail import FastMail
+from api.v1.models import User
+from api.utils.password_auth import hash_password
+import uuid
+
+SQLALCHEMY_DATABASE_URL = "postgresql://postgres:4253@localhost:5432/hng_fast_api"
+engine = create_engine(SQLALCHEMY_DATABASE_URL)
+TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base.metadata.create_all(bind=engine)
+
+
+@pytest.fixture(scope="function")
+def db_session():
+ """Fixture for setting up and tearing down database sessions for tests."""
+ db = TestingSessionLocal()
+ try:
+ yield db
+ finally:
+ db.rollback()
+ db.close()
+
+@pytest.fixture
+def override_get_db(db_session):
+ """Override dependency to use the test database session."""
+ app.dependency_overrides[get_db] = lambda: db_session
+ yield
+ app.dependency_overrides.pop(get_db, None)
+
+
+client = TestClient(app)
+
+
+@pytest.fixture
+def mock_fast_mail():
+ with patch.object(FastMail, "send_message", new_callable=AsyncMock) as mock:
+ yield mock
+
+
+def generate_short_uuid(length=8):
+ # Generate a UUID and get its hex representation (excluding hyphens)
+ short_uuid = uuid.uuid4().hex[:length]
+ return short_uuid
+
+
+def test_send_email_success(mock_fast_mail, override_get_db):
+ unique_email = f"testuser_success_{generate_short_uuid()}" + "@example.com"
+ unique_username = f"testuser_success_{generate_short_uuid()}"
+ user_data = {"email": unique_email, "username": unique_username}
+ db = next(get_db())
+ user = User(**user_data, password=hash_password("testpassword"))
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ response = client.post("/auth/password-reset-email/", json={
+ "email": unique_email,
+ "subject": "Reset Password",
+ "body": "Please reset your password",
+ "body_type": "plain"
+ })
+
+ assert response.status_code == 200
+
+ actual_reset_link = mock_fast_mail.call_args[0][0].body.split(' ')[-1]
+ assert response.json() == {
+ "message": "Password reset email sent successfully.",
+ "reset_link": actual_reset_link
+ }
+ mock_fast_mail.assert_called_once()
+
+
+def test_email_sending_failure(mock_fast_mail, override_get_db):
+ unique_email = f"testuser_success_{generate_short_uuid()}" + "@example.com"
+ unique_username = f"testuser_failure_{generate_short_uuid()}"
+ user_data = {"email": unique_email, "username": unique_username}
+ db = next(get_db())
+ user = User(**user_data, password=hash_password("testpassword"))
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ # Simulate an email sending failure
+ mock_fast_mail.side_effect = Exception("Email sending failed")
+
+ # Act
+ try:
+ response = client.post("/auth/password-reset-email/", json={
+ "email": unique_email,
+ "subject": "Reset Password",
+ "body": "Please reset your password",
+ "body_type": "plain"
+ })
+ # Assert
+ assert response.status_code == 500
+ assert response.json() == {"detail": "Error sending email: Email sending failed"}
+ # expected_response = {"detail": "Error sending email: Email sending failed"}
+ # assert response.json() == expected_response
+ except Exception as e:
+ print(f"Exception during request: {e}")
+
+if __name__ == '__main__':
+ pytest.main()