diff --git a/apis/endpoints/auth.py b/apis/endpoints/auth.py index 38f6762..bef15c6 100644 --- a/apis/endpoints/auth.py +++ b/apis/endpoints/auth.py @@ -1,8 +1,10 @@ -from fastapi import APIRouter +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 +from schemas.BaseSchemas import AuthBase, AuthOTP +from services.clinicServices import ClinicServices +from http import HTTPStatus router = APIRouter() @@ -16,9 +18,31 @@ def login(data: AuthBase): @router.post("/register") -def register(user_data: UserCreate): - token = AuthService().register(user_data) +def register(user_data: UserCreate, background_tasks: BackgroundTasks): + token = AuthService().register(user_data, background_tasks) return ApiResponse( data=token, message="User registered successfully" + ) + +@router.get("/clinic/latest-id") +def get_latest_clinic_id(): + clinic_id = ClinicServices().get_latest_clinic_id() + return ApiResponse( + data=clinic_id, + message="Latest clinic ID retrieved successfully" + ) + + +@router.post("/send-otp") +def send_otp(email: str): + AuthService().send_otp(email) + return HTTPStatus.OK + +@router.post("/verify-otp") +def verify_otp(data: AuthOTP): + AuthService().verify_otp(data) + return ApiResponse( + data="OK", + message="OTP verified successfully" ) \ No newline at end of file diff --git a/apis/endpoints/clinics.py b/apis/endpoints/clinics.py index 98df410..186d7ad 100644 --- a/apis/endpoints/clinics.py +++ b/apis/endpoints/clinics.py @@ -30,12 +30,6 @@ async def get_clinics( clinics = ClinicServices().get_clinics(req.state.user, limit, offset, filter_type, search) return ApiResponse(data=clinics, message="Clinics retrieved successfully" ) -@router.get("/latest-id") -async def get_latest_clinic_id(): - clinic_id = ClinicServices().get_latest_clinic_id() - return ApiResponse(data=clinic_id, message="Latest clinic ID retrieved successfully") - - @router.get("/verified-files/{clinic_id}") async def get_verified_files(clinic_id: int): clinic = ClinicServices().get_clinic_by_id(clinic_id) diff --git a/models/OTP.py b/models/OTP.py new file mode 100644 index 0000000..161aa7b --- /dev/null +++ b/models/OTP.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, String, DateTime + +from database import Base +from .CustomBase import CustomBase + +class OTP(Base, CustomBase): + __tablename__ = "otp" + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), nullable=False) + otp = Column(String(6), nullable=False) + expireAt = Column(DateTime, nullable=False) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py index ec0f574..b516c44 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -13,6 +13,7 @@ from .Fcm import Fcm from .BlockedEmail import BlockedEmail from .SignupPricingMaster import SignupPricingMaster from .ClinicFileVerifications import ClinicFileVerifications +from .OTP import OTP __all__ = [ "Users", @@ -29,5 +30,6 @@ __all__ = [ "Fcm", "BlockedEmail", "SignupPricingMaster", - "ClinicFileVerifications" + "ClinicFileVerifications", + "OTP" ] diff --git a/schemas/BaseSchemas.py b/schemas/BaseSchemas.py index e9de7ff..226490b 100644 --- a/schemas/BaseSchemas.py +++ b/schemas/BaseSchemas.py @@ -13,6 +13,10 @@ class SNSBase(BaseModel): Message: str +class AuthOTP(BaseModel): + email: EmailStr + otp: str + class ClinicFileVerificationBase(BaseModel): abn_doc_is_verified: Optional[bool] = None contract_doc_is_verified: Optional[bool] = None diff --git a/services/authService.py b/services/authService.py index ae79d86..345c722 100644 --- a/services/authService.py +++ b/services/authService.py @@ -1,12 +1,17 @@ +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 utils.constants import generateOTP from utils.password_utils import verify_password from schemas.CreateSchemas import UserCreate -from schemas.BaseSchemas import AuthBase +from schemas.BaseSchemas import AuthBase, AuthOTP from exceptions.unauthorized_exception import UnauthorizedException from database import get_db @@ -17,6 +22,7 @@ class AuthService: def __init__(self): self.user_service = UserServices() self.db = next(get_db()) + self.email_service = EmailService() def login(self, data: AuthBase) -> str: @@ -35,8 +41,8 @@ class AuthService: token = create_jwt_token(user_dict) return token - def register(self, user_data: UserCreate): - response = self.user_service.create_user(user_data) + def register(self, user_data: UserCreate, background_tasks=None): + response = self.user_service.create_user(user_data, background_tasks) user = { "id": response.id, "username": response.username, @@ -73,4 +79,31 @@ class AuthService: self.db.add(blockEmail) self.db.commit() - return "OK" \ No newline at end of file + return "OK" + + def send_otp(self, email:str): + otp = generateOTP() + self.email_service.send_otp_email(email, otp) + + # Create OTP record with proper datetime handling + expire_time = datetime.datetime.now() + datetime.timedelta(minutes=10) + otp_record = OTP(email=email, otp=otp, expireAt=expire_time) + self.db.add(otp_record) + self.db.commit() + + return + + def verify_otp(self, data: AuthOTP): + db_otp = self.db.query(OTP).filter(OTP.email == data.email, OTP.otp == data.otp).first() + if not db_otp: + raise ValidationException("Invalid OTP") + if db_otp.otp != data.otp: + raise ValidationException("Invalid OTP") + if db_otp.expireAt < datetime.datetime.now(): + raise ValidationException("OTP expired") + + # OTP is valid, delete it to prevent reuse + # self.db.delete(db_otp) + # self.db.commit() + + return \ No newline at end of file diff --git a/services/clinicServices.py b/services/clinicServices.py index 88d764c..9bcd948 100644 --- a/services/clinicServices.py +++ b/services/clinicServices.py @@ -9,14 +9,17 @@ from enums.enums import ClinicStatus, UserType from exceptions.unauthorized_exception import UnauthorizedException from interface.common_response import CommonResponse from sqlalchemy import or_,func, case +from sqlalchemy import text from services.s3Service import get_signed_url from models import ClinicFileVerifications from schemas.BaseSchemas import ClinicFileVerificationBase +from services.emailService import EmailService class ClinicServices: def __init__(self): self.db: Session = next(get_db()) + self.email_service = EmailService() def get_clinics(self, user, limit:int, offset:int, filter_type: Union[Literal["UNREGISTERED"], Literal["REGISTERED"]] = "UNREGISTERED", search:str = ""): @@ -42,10 +45,7 @@ class ClinicServices: ) clinics = clinics_query.limit(limit).offset(offset).all() - - # Get all counts in a single optimized query - from sqlalchemy import text - + count_query = text(""" SELECT COUNT(*) as total, @@ -216,6 +216,9 @@ class ClinicServices: self.db.add(clinic_file_verification) self.db.commit() + # send mail to user + self.email_service.send_apporve_clinic_email(clinic.email, clinic.name) + if clinic.status == ClinicStatus.REJECTED or clinic.status == ClinicStatus.UNDER_REVIEW: clinic_file_verification = self.db.query(ClinicFileVerifications).filter(ClinicFileVerifications.clinic_id == clinic_id).first() @@ -238,6 +241,10 @@ class ClinicServices: self.db.commit() + # send mail to user + self.email_service.send_reject_clinic_email(clinic.email, clinic.name) + + # if rejected or under review then email to clinic creator if clinic.status == ClinicStatus.REJECTED or clinic.status == ClinicStatus.UNDER_REVIEW: pass diff --git a/services/emailService.py b/services/emailService.py index 28eedc6..fc868d3 100644 --- a/services/emailService.py +++ b/services/emailService.py @@ -76,7 +76,7 @@ class EmailService: logger.error(f"Failed to create template: {e}") raise Exception("Failed to create template") - def send_email(self, template_name: str, to_address: str, template_data: dict): + def send_email(self, template_name: str, to_address: str, template_data: dict) -> None: """Send an email using a template""" try: response = self.client.send_templated_email( @@ -89,15 +89,47 @@ class EmailService: ReplyToAddresses=[self.senderEmail] ) logger.info(f"Email sent to {to_address} successfully. MessageId: {response['MessageId']}") - return response + return except Exception as e: logger.error(f"Error sending email to {to_address}: {str(e)}") raise - def send_otp_email(self, email: str, otp: str): - """Send OTP email""" - return self.send_email( - template_name="sendOTP", + def send_otp_email(self, email: str, otp: str) -> None: + try: + """Send OTP email""" + self.send_email( + template_name="sendOTP", + to_address=email, + template_data={"otp": otp, "email": email} + ) + return + except Exception as e: + logger.error(f"Error sending OTP email to {email}: {str(e)}") + raise + + def send_new_clinic_email(self, email: str, clinic_name: str): + """Send new clinic email""" + self.send_email( + template_name="newClinic", to_address=email, - template_data={"otp": otp, "email": email} - ) \ No newline at end of file + template_data={"clinic_name": clinic_name, "email": email} + ) + return + + def send_reject_clinic_email(self, email: str, clinic_name: str): + """Send reject clinic email""" + self.send_email( + template_name="rejectClinic", + to_address=email, + template_data={"clinic_name": clinic_name, "email": email} + ) + return + + def send_apporve_clinic_email(self, email: str, clinic_name: str): + """Send apporve clinic email""" + self.send_email( + template_name="apporveClinic", + to_address=email, + template_data={"clinic_name": clinic_name, "email": email} + ) + return \ No newline at end of file diff --git a/services/userServices.py b/services/userServices.py index a3d1bb3..913f77c 100644 --- a/services/userServices.py +++ b/services/userServices.py @@ -17,13 +17,14 @@ from schemas.CreateSchemas import UserCreate from exceptions.resource_not_found_exception import ResourceNotFoundException from exceptions.db_exceptions import DBExceptionHandler from sqlalchemy.orm import joinedload - +from services.emailService import EmailService class UserServices: def __init__(self): self.db: Session = next(get_db()) + self.email_service = EmailService() - def create_user(self, user_data: UserCreate): + def create_user(self, user_data: UserCreate, background_tasks=None): # Start a transaction try: user = user_data.user @@ -116,6 +117,11 @@ class UserServices: # Now commit both user and clinic in a single transaction self.db.commit() + # Send mail to admin in a non-blocking way using background tasks + if background_tasks: + background_tasks.add_task(self._send_emails_to_admins, clinic.email) + # If no background_tasks provided, we don't send emails + return new_user except Exception as e: logger.error(f"Error creating user: {str(e)}") @@ -234,4 +240,19 @@ class UserServices: user.soft_delete(self.db) return True - \ No newline at end of file + + def get_super_admins(self): + return self.db.query(Users).filter(Users.userType == UserType.SUPER_ADMIN).all() + + def _send_emails_to_admins(self, clinic_name): + """Helper method to send emails to all super admins""" + try: + admins = self.get_super_admins() + for admin in admins: + self.email_service.send_new_clinic_email( + to_address=admin.email, + clinic_name=clinic_name + ) + except Exception as e: + # Log the error but don't interrupt the main flow + logger.error(f"Error sending admin emails: {str(e)}") \ No newline at end of file diff --git a/utils.py b/utils.py index e1b1b0a..0d01c86 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,3 @@ # Database pagination constants DEFAULT_SKIP = 0 -DEFAULT_LIMIT = 10 +DEFAULT_LIMIT = 10 \ No newline at end of file diff --git a/utils/constants.py b/utils/constants.py index 3f499bc..3a4c080 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,5 +1,6 @@ import dotenv import os +import random dotenv.load_dotenv() DEFAULT_SKIP = 0 @@ -11,4 +12,23 @@ DEFAULT_ORDER = "desc" # jwt JWT_ALGORITHM = os.getenv("JWT_ALGORITHM") JWT_SECRET = os.getenv("JWT_SECRET") -JWT_EXPIRE_MINUTES = os.getenv("JWT_EXPIRE_MINUTES") \ No newline at end of file +JWT_EXPIRE_MINUTES = os.getenv("JWT_EXPIRE_MINUTES") + +def generateOTP(length: int = 6) -> str: + """ + Generate a secure numeric OTP (One-Time Password) of specified length. + + Args: + length: Length of the OTP to generate (default: 6) + + Returns: + A string containing the generated numeric OTP + """ + if length < 4: + # Ensure minimum security with at least 4 characters + length = 4 + + # Generate a numeric OTP with exactly 'length' digits + # This ensures we get a number with the correct number of digits + # For example, length=6 gives a number between 100000-999999 + return str(random.randint(10**(length-1), 10**length - 1)) \ No newline at end of file