(20250620) SMS sending module also ported for internal use.

This commit is contained in:
2025-06-20 12:15:51 +05:30
parent ff490a55b2
commit 1ec1c0a4ec
3 changed files with 107 additions and 176 deletions
+50 -136
View File
@@ -6,11 +6,11 @@
DATE: DATE:
Wednesday, 19th Jun., 2025 Friday, 20th Jun., 2025
OBJECTIVE: OBJECTIVE:
To generate and verify OTPs that expire. To send out SMS messages for internal use.
REFERENCES: REFERENCES:
@@ -49,6 +49,7 @@ from shared import constants
from utils_v2.string import json from utils_v2.string import json
from utils_v2.date_time import date_time from utils_v2.date_time import date_time
from utils_v2.security.otp import HashedOTP 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.response import ResponseModel
from utils_v2.api.codes import StatusCodes, HttpCodes from utils_v2.api.codes import StatusCodes, HttpCodes
from utils_v2.api.async_quart import ( from utils_v2.api.async_quart import (
@@ -64,7 +65,7 @@ from utils_v2.api.async_quart import (
) )
# Data models: # Data models:
from models.otp.timed_otp import GenerateTimedOTP, VerifyTimedOTP from models.sms.nimbus_sms import NimbusSMSSendRequest
# For asynchronous activities: # For asynchronous activities:
import asyncio import asyncio
@@ -78,7 +79,7 @@ import asyncio
# Related to Quart: # 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): def init(blueprint_setup_state):
# This gets called when the blueprint is registered. # 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") @set_api_version(api_version = "1.0.0")
@read_input(sanitize_headers = True, sanitize_data = True) @read_input(sanitize_headers = True, sanitize_data = True)
@log_request_to_mongo( @log_request_to_mongo(
@@ -123,156 +125,68 @@ def init(blueprint_setup_state):
) )
@log_chain_to_mongo(attr_name = "logs_mongo") @log_chain_to_mongo(attr_name = "logs_mongo")
@should_not_be_under_maintenance(attr_name = "is_under_maintenance") @should_not_be_under_maintenance(attr_name = "is_under_maintenance")
# @only_whitelisted_ips(attr_name = "whitelisted_ips") @only_whitelisted_ips(attr_name = "whitelisted_ips")
@validate_input(data_validator = lambda x: GenerateTimedOTP(**x)) @validate_input(data_validator = lambda x: NimbusSMSSendRequest(**x))
# @limit_rate( @limit_rate(
# attr_name = "otp_redis", attr_name = "rate_limit_cache",
# rate_limit = 1, rate_limit = 1,
# seconds = 2, seconds = 30,
# data_keys = ["id"], data_keys = ["recipientNo"],
# allow_if_exception = False allow_if_exception = True,
# ) count_for_http_codes = [200]
@handle_cancelled_request() )
async def generate_otp( @limit_rate(
inbound_headers: dict = None, attr_name = "rate_limit_cache",
inbound_data: dict | GenerateTimedOTP = None, rate_limit = 5,
inbound_files: dict = None, seconds = 86_400,
log_id: str = None, data_keys = ["recipientNo"],
**kwargs allow_if_exception = False,
): count_for_http_codes = [200]
"""
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
) )
@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() @handle_cancelled_request()
async def verify_otp( async def nimbus_send_sms(
inbound_headers: dict = None, inbound_headers: dict = None,
inbound_data: dict | VerifyTimedOTP = None, inbound_data: dict | NimbusSMSSendRequest = None,
inbound_files: dict = None, inbound_files: dict = None,
log_id: str = None,
**kwargs **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_headers: auto-extracted by the decorators from 'async_quart.py'.
:param inbound_data: 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 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'. :return: A standard response structure from the function in 'async_quart.py'.
""" """
# Start by assuming failure: # Get the default credentials ready:
is_valid = False default_nimbus_creds = current_app.script_data["sms"]["nimbus"]["tcaoff"]
attempts_left = None
# Fetch the OTP from Redis: # Initialise an object of Nimbus SMS:
otp_key = current_app.otp_redis.make_key(str(inbound_data.id)) sms_client = AsyncNimbusSMS(
stored_otp = await current_app.otp_redis.get(key = otp_key) 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: # Send the message out:
if stored_otp is not None: response = await sms_client.send_sms(
template_id = inbound_data.templateId,
# Delete the record from the cache.: message = inbound_data.message,
await current_app.otp_redis.delete(key = otp_key) recipient_number = inbound_data.recipientNo
)
# 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
# Done here: # Done here:
return ResponseModel( 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 = { data = {
"isValid": is_valid, "client": response.client,
"attemptsLeft": attempts_left "ts": response.ts.timestamp(),
}, "messageId": response.messageId,
http_code = HttpCodes.SUCCESS if is_valid else HttpCodes.UNAUTHORIZED "rawResponse": response.rawResponse,
}
) )
+7 -2
View File
@@ -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.cred_and_data.blueprint import cred_and_data_bp
from api_v2.blueprints.logs.blueprint import logs_bp from api_v2.blueprints.logs.blueprint import logs_bp
from api_v2.blueprints.otp.blueprint import otp_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.servers.blueprint import servers_bp
from api_v2.blueprints.test.blueprint import test_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(cred_and_data_bp, url_prefix = f"/{MODULE_BASE}")
app.register_blueprint(logs_bp, url_prefix = f"/{MODULE_BASE}/logs") 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(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(servers_bp, url_prefix = f"/{MODULE_BASE}/servers")
app.register_blueprint(test_bp, url_prefix = f"/{MODULE_BASE}/test") 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"], connection_string = script_cred["redisCache"]["general"]["sentinelJson"],
debug = enable_debugging, 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.") current_app.printer("Redis ready.")
# ┏┓ ┓┓ # ┏┓ ┓┓
+50 -38
View File
@@ -71,25 +71,46 @@ from utils_v2.string import regex
# ***************************************************************************************************************** # *****************************************************************************************************************
class NimbusSendSMS(BaseModel): class NimbusSMSSendRequest(BaseModel):
""" entityId: str | int | None = Field(
This data model is used when the API call is made to send an SMS message. description = "The id given to you by DLT.",
'entityId': is the id given to you by DLT. default = None,
'templateId' is the id given to you by DLT for a template of a message. frozen = True
'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 templateId: str | int = Field(
templateId: str | int description = "The id given to you by DLT for a template of a message.",
recipientNo: str | int frozen = True
message: str )
senderId: str | int
userId: str | int recipientNo: str | int = Field(
apiKey: str 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: class Config:
extra = "forbid" extra = "forbid"
@@ -118,16 +139,19 @@ class NimbusSendSMS(BaseModel):
# --------------------------------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------------------------------
class NimbusGetBalance(BaseModel): class NimbusGetBalanceRequest(BaseModel):
""" userId: str | int | None = Field(
This data model is used when the API call is made to check how much balance is remaining in your Nimbus wallet. description = "The 6-digit id given to you by Nimbus.",
'userId' is the 6-digit id given to you by Nimbus. default = None,
'apiKey' is the key generated on Nimbus's portal. frozen = True
""" )
userId: str | int apiKey: str | None = Field(
apiKey: str description = "The key generated on Nimbus's portal.",
default = None,
frozen = True
)
class Config: class Config:
extra = "forbid" extra = "forbid"
@@ -151,16 +175,4 @@ class NimbusGetBalance(BaseModel):
if __name__ == "__main__": if __name__ == "__main__":
from utils_v2.string import json pass
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()))