From ff490a55b296e652a7565bde1b7cc9f845591991 Mon Sep 17 00:00:00 2001 From: Khushal P Soonderji Date: Fri, 20 Jun 2025 12:14:51 +0530 Subject: [PATCH] (20250620) Minor upgrade to the SMS sending util. --- api_v2/blueprints/sms/__init__.py | 0 api_v2/blueprints/sms/blueprint.py | 288 ++++++++++++++++++ models/sms/__init__.py | 0 models/sms/nimbus_sms.py | 166 ++++++++++ .../india/nimbus/controllers/async_nimbus.py | 1 + utils_v2/sms/models/sms_message.py | 5 + 6 files changed, 460 insertions(+) create mode 100644 api_v2/blueprints/sms/__init__.py create mode 100644 api_v2/blueprints/sms/blueprint.py create mode 100644 models/sms/__init__.py create mode 100644 models/sms/nimbus_sms.py diff --git a/api_v2/blueprints/sms/__init__.py b/api_v2/blueprints/sms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_v2/blueprints/sms/blueprint.py b/api_v2/blueprints/sms/blueprint.py new file mode 100644 index 0000000..fb80dca --- /dev/null +++ b/api_v2/blueprints/sms/blueprint.py @@ -0,0 +1,288 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Wednesday, 19th Jun., 2025 + + OBJECTIVE: + + To generate and verify OTPs that expire. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app + +# Common: +from shared import constants + +# My utils: +from utils_v2.string import json +from utils_v2.date_time import date_time +from utils_v2.security.otp import HashedOTP +from utils_v2.api.response import ResponseModel +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.async_quart import ( + log_request_to_mongo, + only_whitelisted_ips, + should_not_be_under_maintenance, + read_input, + limit_rate, + validate_input, + set_api_version, + log_chain_to_mongo, + handle_cancelled_request +) + +# Data models: +from models.otp.timed_otp import GenerateTimedOTP, VerifyTimedOTP + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +otp_bp = Blueprint("otp", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@otp_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@otp_bp.route("/timed/generate", 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 = "generate", + 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: 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 +) +@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( + inbound_headers: dict = None, + inbound_data: dict | VerifyTimedOTP = None, + inbound_files: dict = None, + log_id: str = None, + **kwargs +): + + """ + Verifies the claimed OTP against the stored OTP. + :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 + + # 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) + + # 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 + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if is_valid else StatusCodes.FAILED, + data = { + "isValid": is_valid, + "attemptsLeft": attempts_left + }, + http_code = HttpCodes.SUCCESS if is_valid else HttpCodes.UNAUTHORIZED + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/sms/__init__.py b/models/sms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/sms/nimbus_sms.py b/models/sms/nimbus_sms.py new file mode 100644 index 0000000..ac73ccc --- /dev/null +++ b/models/sms/nimbus_sms.py @@ -0,0 +1,166 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 10th Sept., 2024. + + OBJECTIVE: + + To provide a data structure for the JSOn received in the API calls to send SMSs through Nimbus IT's service. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data models: +from pydantic import BaseModel, Field, field_validator, Extra +from typing import Optional, Any, Dict +from typing_extensions import Annotated + +# My utils: +from utils_v2.string import regex + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class NimbusSendSMS(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 + templateId: str | int + recipientNo: str | int + message: str + senderId: str | int + userId: str | int + apiKey: str + + class Config: + extra = "forbid" + + def get(self, key: str, default = None): + return getattr(self, key, default) + + @field_validator( + "entityId", + "templateId", + "recipientNo", + "senderId" + ) + def validate_fields(cls, value): + if isinstance(value, str): return value + elif isinstance(value, int): return str(value) + raise ValueError + + @field_validator("userId") + def validate_user_id(cls, value): + if isinstance(value, int): value = str(value) + if regex.match(text = value, pattern = r"^[\d]{6}$"): return value + raise ValueError("userId must be 6-digits long") + + +# --------------------------------------------------------------------------------------------------------------------- + + +class NimbusGetBalance(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 + apiKey: str + + class Config: + extra = "forbid" + + def get(self, key: str, default = None): + return getattr(self, key, default) + + @field_validator("userId") + def validate_user_id(cls, value): + if isinstance(value, int): value = str(value) + if regex.match(text = value, pattern = r"^[\d]{6}$"): return value + raise ValueError("userId must be 6-digits long") + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +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())) diff --git a/utils_v2/sms/india/nimbus/controllers/async_nimbus.py b/utils_v2/sms/india/nimbus/controllers/async_nimbus.py index 0830cbf..d758ebf 100644 --- a/utils_v2/sms/india/nimbus/controllers/async_nimbus.py +++ b/utils_v2/sms/india/nimbus/controllers/async_nimbus.py @@ -199,6 +199,7 @@ class AsyncNimbusSMS: # Construct the basic structure of the response of this method: summary = SentSMSMessageModel( + client = "nimbusSmsIndia", sender = {"senderId": self.__sender_id}, recipient = {"recipientNo": recipient_number}, text = message, diff --git a/utils_v2/sms/models/sms_message.py b/utils_v2/sms/models/sms_message.py index 44c5a18..972f9c2 100644 --- a/utils_v2/sms/models/sms_message.py +++ b/utils_v2/sms/models/sms_message.py @@ -79,6 +79,11 @@ import datetime class SentSMSMessageModel(BaseModel): + client: str = Field( + description = "The name of the client (service) that was used.", + frozen = True + ) + ts: AwareDatetime = Field( description = "the time (utc) at which this message was sent by the sender", frozen = True,