From 1ec1c0a4ece773c7638ab6a11573010a14624e9a Mon Sep 17 00:00:00 2001 From: Khushal P Soonderji Date: Fri, 20 Jun 2025 12:15:51 +0530 Subject: [PATCH] (20250620) SMS sending module also ported for internal use. --- api_v2/blueprints/sms/blueprint.py | 186 ++++++++--------------------- api_v2/main.py | 9 +- models/sms/nimbus_sms.py | 88 ++++++++------ 3 files changed, 107 insertions(+), 176 deletions(-) diff --git a/api_v2/blueprints/sms/blueprint.py b/api_v2/blueprints/sms/blueprint.py index fb80dca..0dd0149 100644 --- a/api_v2/blueprints/sms/blueprint.py +++ b/api_v2/blueprints/sms/blueprint.py @@ -6,11 +6,11 @@ DATE: - Wednesday, 19th Jun., 2025 + Friday, 20th Jun., 2025 OBJECTIVE: - To generate and verify OTPs that expire. + To send out SMS messages for internal use. REFERENCES: @@ -49,6 +49,7 @@ from shared import constants from utils_v2.string import json from utils_v2.date_time import date_time from utils_v2.security.otp import HashedOTP +from utils_v2.sms.india.nimbus.controllers.async_nimbus import AsyncNimbusSMS from utils_v2.api.response import ResponseModel from utils_v2.api.codes import StatusCodes, HttpCodes from utils_v2.api.async_quart import ( @@ -64,7 +65,7 @@ from utils_v2.api.async_quart import ( ) # Data models: -from models.otp.timed_otp import GenerateTimedOTP, VerifyTimedOTP +from models.sms.nimbus_sms import NimbusSMSSendRequest # For asynchronous activities: import asyncio @@ -78,7 +79,7 @@ import asyncio # Related to Quart: -otp_bp = Blueprint("otp", __name__) +sms_bp = Blueprint("sms_bp", __name__) # ***************************************************************************************************************** @@ -98,7 +99,7 @@ otp_bp = Blueprint("otp", __name__) # ***************************************************************************************************************** -@otp_bp.record_once +@sms_bp.record_once def init(blueprint_setup_state): # This gets called when the blueprint is registered. @@ -109,7 +110,8 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- -@otp_bp.route("/timed/generate", methods = ["POST", "GET"]) +@sms_bp.route("/nimbus", methods = ["POST"]) +@sms_bp.route("/nimbus/send", methods = ["POST"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = True, sanitize_data = True) @log_request_to_mongo( @@ -123,156 +125,68 @@ def init(blueprint_setup_state): ) @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") -# @only_whitelisted_ips(attr_name = "whitelisted_ips") -@validate_input(data_validator = lambda x: GenerateTimedOTP(**x)) -# @limit_rate( -# attr_name = "otp_redis", -# rate_limit = 1, -# seconds = 2, -# data_keys = ["id"], -# allow_if_exception = False -# ) -@handle_cancelled_request() -async def generate_otp( - inbound_headers: dict = None, - inbound_data: dict | GenerateTimedOTP = None, - inbound_files: dict = None, - log_id: str = None, - **kwargs -): - - """ - Generates one OTP for the amount of time specified in the request. - :param inbound_headers: auto-extracted by the decorators from 'async_quart.py'. - :param inbound_data: auto-extracted by the decorators from 'async_quart.py'. - :param inbound_files: auto-extracted by the decorators from 'async_quart.py'. - :param log_id: An identifier for the logs (if logging is enabled). - :return: A standard response structure from the function in 'async_quart.py'. - """ - - # Generate the OTP: - otp_key = current_app.otp_redis.make_key(str(inbound_data.id)) - otp_client = HashedOTP(secret = HashedOTP.generate_secret()) - otp = otp_client.generate_otp(count = 0) - - # Delete any existing OTP with the same identifiers: - await current_app.otp_redis.delete(key = otp_key) - - # Store the OTP in Redis: - otp_stored = await current_app.otp_redis.set( - key = otp_key, - value = { - "otp": otp, - "att": inbound_data.attempts, - "iat": date_time.get_current_utc_date_time().timestamp(), - "sec": inbound_data.seconds - }, - expiry = inbound_data.seconds - ) - - # Done here: - return ResponseModel( - status_code = StatusCodes.OK if otp_stored else StatusCodes.FAILED, - data = {"otp": otp} if otp_stored else None - ) - - -# --------------------------------------------------------------------------------------------------------------------- - - -@otp_bp.route("/timed/verify", methods = ["POST", "GET"]) -@set_api_version(api_version = "1.0.0") -@read_input(sanitize_headers = True, sanitize_data = True) -@log_request_to_mongo( - attr_name = "logs_mongo", - project = constants.PROJECT_NAME, - log_type = "timedOTP", - operation = "verify", - log_input = True, - log_output = True, - sensitive_keys = None +@only_whitelisted_ips(attr_name = "whitelisted_ips") +@validate_input(data_validator = lambda x: NimbusSMSSendRequest(**x)) +@limit_rate( + attr_name = "rate_limit_cache", + rate_limit = 1, + seconds = 30, + data_keys = ["recipientNo"], + allow_if_exception = True, + count_for_http_codes = [200] +) +@limit_rate( + attr_name = "rate_limit_cache", + rate_limit = 5, + seconds = 86_400, + data_keys = ["recipientNo"], + allow_if_exception = False, + count_for_http_codes = [200] ) -@log_chain_to_mongo(attr_name = "logs_mongo") -@should_not_be_under_maintenance(attr_name = "is_under_maintenance") -# @only_whitelisted_ips(attr_name = "whitelisted_ips") -@validate_input(data_validator = lambda x: VerifyTimedOTP(**x)) -# @limit_rate( -# attr_name = "otp_redis", -# rate_limit = 1, -# seconds = 2, -# data_keys = ["id"], -# allow_if_exception = False, -# count_for_http_codes = [200] -# ) @handle_cancelled_request() -async def verify_otp( +async def nimbus_send_sms( inbound_headers: dict = None, - inbound_data: dict | VerifyTimedOTP = None, + inbound_data: dict | NimbusSMSSendRequest = None, inbound_files: dict = None, - log_id: str = None, **kwargs ): """ - Verifies the claimed OTP against the stored OTP. + Sends one SMS out through Nimbus IT's system. :param inbound_headers: auto-extracted by the decorators from 'async_quart.py'. :param inbound_data: auto-extracted by the decorators from 'async_quart.py'. :param inbound_files: auto-extracted by the decorators from 'async_quart.py'. - :param log_id: An identifier for the logs (if logging is enabled). :return: A standard response structure from the function in 'async_quart.py'. """ - # Start by assuming failure: - is_valid = False - attempts_left = None + # Get the default credentials ready: + default_nimbus_creds = current_app.script_data["sms"]["nimbus"]["tcaoff"] - # Fetch the OTP from Redis: - otp_key = current_app.otp_redis.make_key(str(inbound_data.id)) - stored_otp = await current_app.otp_redis.get(key = otp_key) + # Initialise an object of Nimbus SMS: + sms_client = AsyncNimbusSMS( + entity_id = inbound_data.entityId or default_nimbus_creds["entityId"], + sender_id = inbound_data.senderId or default_nimbus_creds["senderId"], + user_id = inbound_data.userId or default_nimbus_creds["userId"], + api_key = inbound_data.apiKey or default_nimbus_creds["apiKey"] + ) - # Test the validity of the OTP: - if stored_otp is not None: - - # Delete the record from the cache.: - await current_app.otp_redis.delete(key = otp_key) - - # Make note of the attempts left. - # We reduce the count by one straightaway: - attempts_left = stored_otp["att"] - 1 - - # If the stored OTP matches the claimed OTP, - # we note down the acceptance and delete the record from the cache: - if inbound_data.otp == stored_otp["otp"]: - is_valid = True - attempts_left = 0 - - # If the stored OTP and the claimed OTP don't match, - # we reduce the attempt count and : - elif attempts_left > 0: - - # Update the OTP info in the cache: - otp_updated = await current_app.otp_redis.set( - key = otp_key, - value = { - "otp": stored_otp["otp"], - "att": attempts_left, - "iat": stored_otp["iat"], - "sec": stored_otp["sec"] - }, - expiry = (stored_otp["iat"] + stored_otp["sec"]) - date_time.get_current_utc_date_time().timestamp() - ) - - # If the update failed: - if not otp_updated: attempts_left = None + # Send the message out: + response = await sms_client.send_sms( + template_id = inbound_data.templateId, + message = inbound_data.message, + recipient_number = inbound_data.recipientNo + ) # Done here: return ResponseModel( - status_code = StatusCodes.OK if is_valid else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if response.success else HttpCodes.INTERNAL_SERVER_ERROR, + status_code = StatusCodes.OK if response.success else StatusCodes.FAILED, data = { - "isValid": is_valid, - "attemptsLeft": attempts_left - }, - http_code = HttpCodes.SUCCESS if is_valid else HttpCodes.UNAUTHORIZED + "client": response.client, + "ts": response.ts.timestamp(), + "messageId": response.messageId, + "rawResponse": response.rawResponse, + } ) diff --git a/api_v2/main.py b/api_v2/main.py index 99572fa..202f5be 100644 --- a/api_v2/main.py +++ b/api_v2/main.py @@ -75,6 +75,7 @@ from controllers.servers.server import CoreServerController from api_v2.blueprints.cred_and_data.blueprint import cred_and_data_bp from api_v2.blueprints.logs.blueprint import logs_bp from api_v2.blueprints.otp.blueprint import otp_bp +from api_v2.blueprints.sms.blueprint import sms_bp from api_v2.blueprints.servers.blueprint import servers_bp from api_v2.blueprints.test.blueprint import test_bp @@ -104,6 +105,7 @@ app = cors(app) app.register_blueprint(cred_and_data_bp, url_prefix = f"/{MODULE_BASE}") app.register_blueprint(logs_bp, url_prefix = f"/{MODULE_BASE}/logs") app.register_blueprint(otp_bp, url_prefix = f"/{MODULE_BASE}/otp") +app.register_blueprint(sms_bp, url_prefix = f"/{MODULE_BASE}/sms") app.register_blueprint(servers_bp, url_prefix = f"/{MODULE_BASE}/servers") app.register_blueprint(test_bp, url_prefix = f"/{MODULE_BASE}/test") @@ -192,12 +194,15 @@ async def app_startup(**kwargs): # ┣┫┏┓┏┫┓┏ ┃ ┏┓┏┣┓┏┓ # ┛┗┗ ┗┻┗┛ ┗┛┗┻┗┛┗┗ - current_app.otp_redis = AsyncRedisCache( + current_app.redis_cache = AsyncRedisCache( connection_string = script_cred["redisCache"]["general"]["sentinelJson"], debug = enable_debugging, - debug_prefix = "OTP Cache | " + debug_prefix = "Redis Cache | " ) + current_app.otp_redis = current_app.redis_cache + current_app.rate_limit_cache = current_app.redis_cache + current_app.printer("Redis ready.") # ┏┓ ┓┓ diff --git a/models/sms/nimbus_sms.py b/models/sms/nimbus_sms.py index ac73ccc..5c633f3 100644 --- a/models/sms/nimbus_sms.py +++ b/models/sms/nimbus_sms.py @@ -71,25 +71,46 @@ from utils_v2.string import regex # ***************************************************************************************************************** -class NimbusSendSMS(BaseModel): +class NimbusSMSSendRequest(BaseModel): - """ - This data model is used when the API call is made to send an SMS message. - 'entityId': is the id given to you by DLT. - 'templateId' is the id given to you by DLT for a template of a message. - 'recipientNo' is the phone number of the person you want to send the message to. - 'senderId' 6-char code like "HDFCBK", "NSESMS", "ZRODHA" that you see in your SMS inbox. - 'userId' is the 6-digit id given to you by Nimbus. - 'apiKey' is the key generated on Nimbus's portal. - """ + entityId: str | int | None = Field( + description = "The id given to you by DLT.", + default = None, + frozen = True + ) - entityId: str | int - templateId: str | int - recipientNo: str | int - message: str - senderId: str | int - userId: str | int - apiKey: str + templateId: str | int = Field( + description = "The id given to you by DLT for a template of a message.", + frozen = True + ) + + recipientNo: str | int = Field( + description = "The phone number of the person you want to send the message to.", + frozen = True + ) + + message: str = Field( + description = "The message you want to send.", + frozen = True + ) + + senderId: str | int | None = Field( + description = "6-char code like 'HDFCBK', 'NSESMS', 'ZRODHA' that you see in your SMS inbox.", + default = None, + frozen = True + ) + + userId: str | int | None = Field( + description = "The 6-digit id given to you by Nimbus.", + default = None, + frozen = True + ) + + apiKey: str | None = Field( + description = "The key generated on Nimbus's portal.", + default = None, + frozen = True + ) class Config: extra = "forbid" @@ -118,16 +139,19 @@ class NimbusSendSMS(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class NimbusGetBalance(BaseModel): +class NimbusGetBalanceRequest(BaseModel): - """ - This data model is used when the API call is made to check how much balance is remaining in your Nimbus wallet. - 'userId' is the 6-digit id given to you by Nimbus. - 'apiKey' is the key generated on Nimbus's portal. - """ + userId: str | int | None = Field( + description = "The 6-digit id given to you by Nimbus.", + default = None, + frozen = True + ) - userId: str | int - apiKey: str + apiKey: str | None = Field( + description = "The key generated on Nimbus's portal.", + default = None, + frozen = True + ) class Config: extra = "forbid" @@ -151,16 +175,4 @@ class NimbusGetBalance(BaseModel): if __name__ == "__main__": - from utils_v2.string import json - - my_msg = NimbusSendSMS( - entityId = 123, - templateId = 456, - recipientNo = "789", - message = "Hello, World!", - senderId = "TCAOFF", - userId = "123456", - apiKey = "123@ABC" - ) - - print(json.to_string(my_msg.model_dump())) + pass