From 9c39d369c19d32b9df3c7c25aa2de608cd89efce Mon Sep 17 00:00:00 2001 From: deepvasoya Date: Wed, 21 May 2025 15:59:33 +0530 Subject: [PATCH] feat: staff management apis --- apis/endpoints/admin.py | 20 ++- apis/endpoints/auth.py | 13 +- main.py | 3 + .../48785e34c37c_updated_user_table2.py | 35 +++++ .../7d1c821a7e05_updated_user_table.py | 32 +++++ models/ResetPasswordTokens.py | 11 ++ models/Users.py | 2 +- models/__init__.py | 4 +- schemas/BaseSchemas.py | 12 +- services/authService.py | 127 +++++++++++++++++- services/emailService.py | 61 ++++++++- services/userServices.py | 17 ++- utils/password_utils.py | 44 ++++++ 13 files changed, 360 insertions(+), 21 deletions(-) create mode 100644 migrations/versions/48785e34c37c_updated_user_table2.py create mode 100644 migrations/versions/7d1c821a7e05_updated_user_table.py create mode 100644 models/ResetPasswordTokens.py diff --git a/apis/endpoints/admin.py b/apis/endpoints/admin.py index 56622a8..09b3f0b 100644 --- a/apis/endpoints/admin.py +++ b/apis/endpoints/admin.py @@ -1,8 +1,11 @@ -from fastapi import APIRouter, Request, status +from fastapi import APIRouter, Request from services.clinicServices import ClinicServices from schemas.UpdateSchemas import ClinicStatusUpdate from schemas.ApiResponse import ApiResponse +from schemas.BaseSchemas import CreateSuperAdmin, ResetPasswordBase +from services.authService import AuthService +from utils.constants import DEFAULT_LIMIT, DEFAULT_PAGE router = APIRouter() @@ -11,3 +14,18 @@ router = APIRouter() def update_clinic_status(req:Request, data: ClinicStatusUpdate): response = ClinicServices().update_clinic_status(req.state.user, data.clinic_id, data.status, data.documentStatus, data.rejection_reason) return ApiResponse(data=response, message="Clinic status updated successfully") + + +@router.post("/user") +def create_user(req:Request, user_data: CreateSuperAdmin): + AuthService().create_super_admin(req.state.user, user_data) + return ApiResponse(data="OK", message="User created successfully") + + +@router.get("/") +def get_users(req:Request, limit:int = DEFAULT_LIMIT, page:int = DEFAULT_PAGE, search:str = ""): + if page < 1: + page = 1 + offset = (page - 1) * limit + users = AuthService().get_admins(req.state.user, limit, offset, search) + return ApiResponse(data=users, message="Users retrieved successfully") diff --git a/apis/endpoints/auth.py b/apis/endpoints/auth.py index bef15c6..5798810 100644 --- a/apis/endpoints/auth.py +++ b/apis/endpoints/auth.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, BackgroundTasks from services.authService import AuthService from schemas.CreateSchemas import UserCreate from schemas.ApiResponse import ApiResponse -from schemas.BaseSchemas import AuthBase, AuthOTP +from schemas.BaseSchemas import AuthBase, AuthOTP, ResetPasswordBase from services.clinicServices import ClinicServices from http import HTTPStatus @@ -34,6 +34,17 @@ def get_latest_clinic_id(): ) +@router.post('/admin/forget-password') +def forget_password(email: str): + AuthService().forget_password(email) + return ApiResponse(data="OK", message="Password reset email sent successfully") + +@router.post('/admin/reset-password') +def reset_password(data: ResetPasswordBase): + AuthService().reset_password(data.token, data.password) + return ApiResponse(data="OK", message="Password reset successfully") + + @router.post("/send-otp") def send_otp(email: str): AuthService().send_otp(email) diff --git a/main.py b/main.py index a0c82cf..01c2024 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from apis import api_router # middleware from middleware.ErrorHandlerMiddleware import ErrorHandlerMiddleware, configure_exception_handlers from middleware.CustomRequestTypeMiddleware import TextPlainMiddleware +from services.emailService import EmailService dotenv.load_dotenv() @@ -69,6 +70,8 @@ configure_exception_handlers(app) @app.get("/") async def hello_world(): + # email_service = EmailService() + # email_service.createTemplate() return {"Hello": "World"} diff --git a/migrations/versions/48785e34c37c_updated_user_table2.py b/migrations/versions/48785e34c37c_updated_user_table2.py new file mode 100644 index 0000000..4c91fd3 --- /dev/null +++ b/migrations/versions/48785e34c37c_updated_user_table2.py @@ -0,0 +1,35 @@ +"""updated_user_table2 + +Revision ID: 48785e34c37c +Revises: 7d1c821a7e05 +Create Date: 2025-05-21 13:54:54.833038 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '48785e34c37c' +down_revision: Union[str, None] = '7d1c821a7e05' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # op.drop_index('ix_users_username', table_name='users') + # op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=False) + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_username'), table_name='users') + op.create_index('ix_users_username', 'users', ['username'], unique=True) + # ### end Alembic commands ### diff --git a/migrations/versions/7d1c821a7e05_updated_user_table.py b/migrations/versions/7d1c821a7e05_updated_user_table.py new file mode 100644 index 0000000..de5c194 --- /dev/null +++ b/migrations/versions/7d1c821a7e05_updated_user_table.py @@ -0,0 +1,32 @@ +"""updated_user_table + +Revision ID: 7d1c821a7e05 +Revises: ec157808ef2a +Create Date: 2025-05-21 13:51:39.680812 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7d1c821a7e05' +down_revision: Union[str, None] = 'ec157808ef2a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'mobile', nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'mobile', nullable=False) + # ### end Alembic commands ### diff --git a/models/ResetPasswordTokens.py b/models/ResetPasswordTokens.py new file mode 100644 index 0000000..28a0d62 --- /dev/null +++ b/models/ResetPasswordTokens.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, String +from database import Base +from .CustomBase import CustomBase + + +class ResetPasswordTokens(Base, CustomBase): + __tablename__ = "reset_password_tokens" + id = Column(Integer, primary_key=True) + email = Column(String) + token = Column(String) + \ No newline at end of file diff --git a/models/Users.py b/models/Users.py index 3cddf1b..ba3e76c 100644 --- a/models/Users.py +++ b/models/Users.py @@ -14,7 +14,7 @@ class Users(Base, CustomBase): clinicRole = Column(Enum(ClinicUserRoles), nullable=True) userType = Column(Enum(UserType), nullable=True) profile_pic = Column(String, nullable=True) - mobile = Column(String) + mobile = Column(String, nullable=True) # Notification relationships sent_notifications = relationship("Notifications", foreign_keys="Notifications.sender_id", back_populates="sender") diff --git a/models/__init__.py b/models/__init__.py index b516c44..05e91f9 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -14,6 +14,7 @@ from .BlockedEmail import BlockedEmail from .SignupPricingMaster import SignupPricingMaster from .ClinicFileVerifications import ClinicFileVerifications from .OTP import OTP +from .ResetPasswordTokens import ResetPasswordTokens __all__ = [ "Users", @@ -31,5 +32,6 @@ __all__ = [ "BlockedEmail", "SignupPricingMaster", "ClinicFileVerifications", - "OTP" + "OTP", + "ResetPasswordTokens" ] diff --git a/schemas/BaseSchemas.py b/schemas/BaseSchemas.py index 226490b..a9ede96 100644 --- a/schemas/BaseSchemas.py +++ b/schemas/BaseSchemas.py @@ -11,7 +11,6 @@ class SNSBase(BaseModel): TopicArn: str SubscribeURL: str Message: str - class AuthOTP(BaseModel): email: EmailStr @@ -33,6 +32,15 @@ class AuthBase(BaseModel): email: EmailStr password: str + +class ResetPasswordBase(BaseModel): + token: str + password: str + +class CreateSuperAdmin(BaseModel): + username:str + email:EmailStr + # Base schemas (shared attributes for create/read operations) class ClinicBase(BaseModel): name: str @@ -101,7 +109,7 @@ class UserBase(BaseModel): password: str clinicRole: Optional[ClinicUserRoles] = None userType: Optional[UserType] = None - mobile: str + mobile: Optional[str] = None class ClinicDoctorBase(BaseModel): diff --git a/services/authService.py b/services/authService.py index 345c722..e5d36b2 100644 --- a/services/authService.py +++ b/services/authService.py @@ -1,17 +1,28 @@ +from operator import or_ +import os +import dotenv + +dotenv.load_dotenv() + +from interface.common_response import CommonResponse +from schemas.ResponseSchemas import UserResponse import datetime import json import urllib.request -from sqlalchemy.orm import Session from services.jwtService import create_jwt_token from services.userServices import UserServices from models import BlockedEmail from services.emailService import EmailService from exceptions.validation_exception import ValidationException from models import OTP +from enums.enums import UserType +from models import Users +from exceptions.resource_not_found_exception import ResourceNotFoundException +from models import ResetPasswordTokens from utils.constants import generateOTP -from utils.password_utils import verify_password +from utils.password_utils import generate_reset_password_token, generate_secure_password, hash_password, verify_password from schemas.CreateSchemas import UserCreate -from schemas.BaseSchemas import AuthBase, AuthOTP +from schemas.BaseSchemas import AuthBase, AuthOTP, CreateSuperAdmin from exceptions.unauthorized_exception import UnauthorizedException from database import get_db @@ -23,6 +34,7 @@ class AuthService: self.user_service = UserServices() self.db = next(get_db()) self.email_service = EmailService() + self.url = os.getenv("FRONTEND_URL") def login(self, data: AuthBase) -> str: @@ -106,4 +118,111 @@ class AuthService: # self.db.delete(db_otp) # self.db.commit() - return \ No newline at end of file + return + + + def get_admins(self, user, limit:int, offset:int, search:str): + try: + if user["userType"] != UserType.SUPER_ADMIN: + raise UnauthorizedException("User is not authorized to perform this action") + + admins = self.db.query(Users).filter(Users.userType == UserType.SUPER_ADMIN) + + total = self.db.query(Users).filter(Users.userType == UserType.SUPER_ADMIN).count() + + if search: + admins = admins.filter( + or_( + Users.username.contains(search), + Users.email.contains(search), + ) + ) + + total = admins.count() + + admins = admins.limit(limit).offset(offset).all() + + response = [UserResponse(**admin.__dict__.copy()) for admin in admins] + + common_response = CommonResponse(data=response, total=total) + + return common_response + except Exception as e: + raise e + + def create_super_admin(self, user, data: CreateSuperAdmin): + + if user["userType"] != UserType.SUPER_ADMIN: + raise UnauthorizedException("User is not authorized to perform this action") + + password = generate_secure_password() + hashed_password = hash_password(password) + + # check if username and email are unique + existing_user = ( + self.db.query(Users) + .filter( + Users.email == data.email.lower(), + ) + .first() + ) + + if existing_user: + raise ValidationException("User with same email already exists") + + user = Users( + username=data.username.lower(), + email=data.email.lower(), + password=hashed_password, + userType=UserType.SUPER_ADMIN, + ) + + + self.db.add(user) + self.db.commit() + + + LOGIN_URL = self.url + + # send email to user + self.email_service.send_new_admin_email(data.email, password, LOGIN_URL) + + return + + + def forget_password(self, email: str): + user = self.db.query(Users).filter(Users.email == email.lower()).first() + + if not user: + raise ResourceNotFoundException("User not found") + + # get reset password token + reset_password_token = generate_reset_password_token() + + reset_password = ResetPasswordTokens(email=email, token=reset_password_token) + self.db.add(reset_password) + self.db.commit() + + reset_password_url = f"{self.url}/auth/reset-password?token={reset_password_token}" + + self.email_service.send_reset_password_email(email, reset_password_url) + + return + + def reset_password(self, token: str, password: str): + reset_password = self.db.query(ResetPasswordTokens).filter(ResetPasswordTokens.token == token).first() + + if not reset_password: + raise ResourceNotFoundException("Reset password token not found") + + user = self.db.query(Users).filter(Users.email == reset_password.email).first() + + if not user: + raise ResourceNotFoundException("User not found") + + user.password = hash_password(password) + self.db.delete(reset_password) + self.db.commit() + + return + \ No newline at end of file diff --git a/services/emailService.py b/services/emailService.py index fc868d3..547fd75 100644 --- a/services/emailService.py +++ b/services/emailService.py @@ -67,10 +67,45 @@ class EmailService: "TextPart": "Dear User, Congratulations! Your clinic {{name}} has been approved. Thank you, Team 24x7 AIHR" } - self.client.create_template(Template=otp_template) - self.client.create_template(Template=new_clinic_template) - self.client.create_template(Template=reject_clinic_template) - self.client.create_template(Template=apporve_clinic_template) + new_admin_template = { + "TemplateName": "newAdmin", + "SubjectPart": "Login Credentials", + "HtmlPart": """ +

Dear User,

+

Your login credentials are:

+
+

Email: {{email}}

+

Password: {{password}}

+
+
+

Use the following link to login:

+

Login URL: {{login_url}}

+
+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear User, Your login credentials are: Email: {{email}} Password: {{password}} Login URL: {{login_url}} Thank you, Team 24x7 AIHR" + } + + reset_password_template = { + "TemplateName": "resetPassword", + "SubjectPart": "Reset Password", + "HtmlPart": """ +

Dear User,

+

You have requested to reset your password. Please use the following link to reset your password:

+

Reset Password URL: {{reset_password_url}}

+
+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear User, You have requested to reset your password. Please use the following link to reset your password: Reset Password URL: {{reset_password_url}} Thank you, Team 24x7 AIHR" + } + + # self.client.create_template(Template=otp_template) + # self.client.create_template(Template=new_clinic_template) + # self.client.create_template(Template=reject_clinic_template) + # self.client.create_template(Template=apporve_clinic_template) + # self.client.create_template(Template=new_admin_template) + # self.client.create_template(Template=new_admin_template) + self.client.create_template(Template=reset_password_template) except Exception as e: logger.error(f"Failed to create template: {e}") @@ -132,4 +167,22 @@ class EmailService: to_address=email, template_data={"clinic_name": clinic_name, "email": email} ) + return + + def send_new_admin_email(self, email: str, password:str, login_url:str): + """Send new admin email""" + self.send_email( + template_name="newAdmin", + to_address=email, + template_data={"email": email, "password": password, "login_url": login_url} + ) + return + + def send_reset_password_email(self, email: str, reset_password_url: str): + """Send reset password email""" + self.send_email( + template_name="resetPassword", + to_address=email, + template_data={"reset_password_url": reset_password_url} + ) return \ No newline at end of file diff --git a/services/userServices.py b/services/userServices.py index 913f77c..661b153 100644 --- a/services/userServices.py +++ b/services/userServices.py @@ -186,17 +186,20 @@ class UserServices: return response def get_user_by_email(self, email: str) -> UserResponse: - user = self.db.query(Users).filter(Users.email == email.lower()).first() + try: + user = self.db.query(Users).filter(Users.email == email.lower()).first() - if not user: - logger.error("User not found") - raise ResourceNotFoundException("User not found") + if not user: + logger.error("User not found") + raise ResourceNotFoundException("User not found") - user_dict = user.__dict__.copy() + user_dict = user.__dict__.copy() - user_response = UserResponse(**user_dict) + user_response = UserResponse(**user_dict) - return user_response + return user_response + except Exception as e: + raise e def update_user(self, admin_id:int|None, user_id: int, user_data: UserUpdate) -> UserResponse: # Check admin authorization if admin_id is provided diff --git a/utils/password_utils.py b/utils/password_utils.py index d7c6b15..cd6532d 100644 --- a/utils/password_utils.py +++ b/utils/password_utils.py @@ -1,8 +1,13 @@ from passlib.context import CryptContext +import string +import secrets # Create a password context for hashing and verifying pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +def generate_reset_password_token(): + return secrets.token_urlsafe(32) + def hash_password(password: str) -> str: """ Hash a password using bcrypt @@ -14,3 +19,42 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: Verify a password against a hash """ return pwd_context.verify(plain_password, hashed_password) + + +def generate_secure_password(length: int = 16, include_special: bool = True) -> str: + """ + Generate a cryptographically secure random password + + Args: + length: Length of the password (default 16) + include_special: Include special characters (default True) + + Returns: + A secure random password string + """ + # Define character sets + lowercase = string.ascii_lowercase + uppercase = string.ascii_uppercase + digits = string.digits + special_chars = string.punctuation if include_special else "" + + # Combined character set + all_chars = lowercase + uppercase + digits + special_chars + + # Ensure at least one character from each required group + password = [ + secrets.choice(lowercase), + secrets.choice(uppercase), + secrets.choice(digits) + ] + + if include_special and special_chars: + password.append(secrets.choice(special_chars)) + + # Fill remaining length with random characters + password.extend(secrets.choice(all_chars) for _ in range(length - len(password))) + + # Shuffle the password characters + secrets.SystemRandom().shuffle(password) + + return ''.join(password) \ No newline at end of file