From 79b771b48cc698163db8ceeda74f54c3f9f735e4 Mon Sep 17 00:00:00 2001 From: khushal Date: Thu, 19 Dec 2024 17:26:18 +0530 Subject: [PATCH] (20241219) SMS module fully re-organized. --- api/blueprints/finstitutions/payments/get.py | 2 +- api/blueprints/finstitutions/payments/tags.py | 2 +- api/blueprints/mail/oauth/callback.py | 2 +- api/blueprints/mail/oauth/request.py | 2 +- api/blueprints/mail/retrieve/get.py | 2 +- api/blueprints/mail/retrieve/list.py | 2 +- api/blueprints/mail/send/send.py | 56 ++- api/blueprints/mail/sync/sync_v2.py | 2 +- api/blueprints/mail/tags/update.py | 2 +- api/blueprints/sms/auth_v2.py | 19 +- api/blueprints/sms/list.py | 138 +++--- api/blueprints/sms/send_v2.py | 102 ++++- api/blueprints/sms/tags.py | 84 ++-- api/main.py | 48 +- controllers/api/mail.py | 113 ++++- controllers_v2/core/auth_token.py | 214 +++++++-- controllers_v2/core/base.py | 36 +- controllers_v2/core/message.py | 234 ++++++---- controllers_v2/sms/all_sms.py | 92 +--- controllers_v2/sms/base.py | 418 ++++-------------- controllers_v2/sms/nimbus_sms_india.py | 167 +++++-- controllers_v2/sms/savvy_bulk_sms_kenya.py | 39 +- models/api/mail/send.py | 87 +++- models/api/mail/sync.py | 29 ++ models/api/sms/list.py | 4 +- models/api/sms/send.py | 2 +- models/api/sms/tags.py | 8 +- models/core/message.py | 7 +- 28 files changed, 1086 insertions(+), 827 deletions(-) diff --git a/api/blueprints/finstitutions/payments/get.py b/api/blueprints/finstitutions/payments/get.py index 4a1def2..fb39c80 100644 --- a/api/blueprints/finstitutions/payments/get.py +++ b/api/blueprints/finstitutions/payments/get.py @@ -62,7 +62,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/finstitutions/payments/tags.py b/api/blueprints/finstitutions/payments/tags.py index c502c58..1ed5d6e 100644 --- a/api/blueprints/finstitutions/payments/tags.py +++ b/api/blueprints/finstitutions/payments/tags.py @@ -62,7 +62,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/mail/oauth/callback.py b/api/blueprints/mail/oauth/callback.py index cd92690..a72d192 100644 --- a/api/blueprints/mail/oauth/callback.py +++ b/api/blueprints/mail/oauth/callback.py @@ -61,7 +61,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT # Data Models: from models.core.auth_token import CoreAuthTokenModel diff --git a/api/blueprints/mail/oauth/request.py b/api/blueprints/mail/oauth/request.py index ed18356..aa75ba1 100644 --- a/api/blueprints/mail/oauth/request.py +++ b/api/blueprints/mail/oauth/request.py @@ -61,7 +61,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT # Common: from shared import constants diff --git a/api/blueprints/mail/retrieve/get.py b/api/blueprints/mail/retrieve/get.py index ec1b46e..93966f5 100644 --- a/api/blueprints/mail/retrieve/get.py +++ b/api/blueprints/mail/retrieve/get.py @@ -62,7 +62,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/mail/retrieve/list.py b/api/blueprints/mail/retrieve/list.py index f065b97..38f8f72 100644 --- a/api/blueprints/mail/retrieve/list.py +++ b/api/blueprints/mail/retrieve/list.py @@ -63,7 +63,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/mail/send/send.py b/api/blueprints/mail/send/send.py index 0532da4..1d93156 100644 --- a/api/blueprints/mail/send/send.py +++ b/api/blueprints/mail/send/send.py @@ -62,7 +62,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: @@ -140,19 +140,19 @@ def init(blueprint_setup_state): @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @validate_input( - header_validator = lambda x: MailUpdateTagsRequestHeaders(**x).model_dump(), - data_validator = lambda x: MailUpdateTagsRequestData(**x) + header_validator = lambda x: MailSendRequestHeaders(**x).model_dump(), + data_validator = lambda x: MailSendRequestData(**x) ) @handle_cancelled_request() async def send_one_mail( - inbound_headers: dict | MailUpdateTagsRequestHeaders = None, - inbound_data: dict | MailUpdateTagsRequestData = None, + inbound_headers: dict | MailSendRequestHeaders = None, + inbound_data: dict | MailSendRequestData = None, inbound_files: dict = None, **kwargs ): """ - Use this endpoint to update the tags on an e-mail message. + Use this endpoint to send one mail message. :param inbound_headers: auto-extracted by the decorators. :param inbound_data: auto-extracted by the decorators. :param inbound_files: auto-extracted by the decorators. @@ -160,6 +160,9 @@ async def send_one_mail( :return: A standard response structure. """ + # Start by assuming failure: + success = False + # ┏┓ ┓ ┏┓┓ ┓ # ┣┫┓┏╋┣┓ ┃ ┣┓┏┓┏┃┏ # ┛┗┗┻┗┛┗ ┗┛┛┗┗ ┗┛┗ @@ -171,44 +174,37 @@ async def send_one_mail( http_code = HttpCodes.UNAUTHORIZED ) - # ┏┓ ┓ ┳┳┓ •┓ - # ┣ ┏┓╋┏┣┓ ┃┃┃┏┓┓┃ - # ┻ ┗ ┗┗┛┗ ┛ ┗┗┻┗┗ - - # Get the mail: - message = await current_app.mail_controller.get_one_mail( - mongo_conn = current_app.data_mongo, - message_id = inbound_data.messageId - ) - # ┏┓ ┓ • ┏┓┓ ┓ # ┃┃┓┏┏┏┓┏┓┏┓┏┣┓┓┏┓ ┃ ┣┓┏┓┏┃┏ # ┗┛┗┻┛┛┗┗ ┛ ┛┛┗┗┣┛ ┗┛┛┗┗ ┗┛┗ # ┛ + # Get the token based on the key: + auth_token = await current_app.mail_controller.get_token_from_key( + mongo_conn = current_app.data_mongo, + token_key = inbound_data.tokenKey + ) + + print("INBOUND DATA:", json.to_string(inbound_data.model_dump(), default=str)) + print("INBOUND FILES:", json.to_string(inbound_files, default=str)) + print("AUTH TOKEN:", json.to_string(auth_token, default=str)) + # We check if the token that was used to fetch the mail is owned by this user: if not await token_check.is_authorized( - mongo_conn=current_app.data_mongo, + mongo_conn = current_app.data_mongo, user_info = CoreUserInfoModel(**kwargs["session_info"]), - token_ids = [message.tokenId] + token_ids = [auth_token.authTokenId] ): return ResponseModel( status_code = StatusCodes.FAILED, http_code = HttpCodes.UNAUTHORIZED, - message = "The message does not belong to this user." + message = "The account does not belong to this user." ) - # ┳┳ ┓ ┳┳┓ •┓ - # ┃┃┏┓┏┫┏┓╋┏┓ ┃┃┃┏┓┓┃ - # ┗┛┣┛┗┻┗┻┗┗ ┛ ┗┗┻┗┗ - # ┛ + # ┏┓ ┓ ┳┳┓ •┓ + # ┗┓┏┓┏┓┏┫ ┃┃┃┏┓┓┃ + # ┗┛┗ ┛┗┗┻ ┛ ┗┗┻┗┗ - # Update the mail: - success = await current_app.mail_controller.update_tags( - mongo_conn = current_app.data_mongo, - message_id = inbound_data.messageId, - unset_tags = inbound_data.unsetTags, - set_tags = inbound_data.setTags - ) + pass # ┳┓ # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ diff --git a/api/blueprints/mail/sync/sync_v2.py b/api/blueprints/mail/sync/sync_v2.py index e2907c4..2277c7f 100644 --- a/api/blueprints/mail/sync/sync_v2.py +++ b/api/blueprints/mail/sync/sync_v2.py @@ -63,7 +63,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/mail/tags/update.py b/api/blueprints/mail/tags/update.py index 6e56900..a1040f2 100644 --- a/api/blueprints/mail/tags/update.py +++ b/api/blueprints/mail/tags/update.py @@ -62,7 +62,7 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: diff --git a/api/blueprints/sms/auth_v2.py b/api/blueprints/sms/auth_v2.py index 0fae3ac..11c49ab 100644 --- a/api/blueprints/sms/auth_v2.py +++ b/api/blueprints/sms/auth_v2.py @@ -6,7 +6,7 @@ DATE: - Thursday, 5th Dec., 2024 + Thursday, 19th Dec., 2024 OBJECTIVE: @@ -145,10 +145,9 @@ async def authorize_sms_client( :return: A standard response structure. """ - # ┏┓ - # ┃┃┏┓┏┓┏┓┏┓┏┓┏┏┓┏┏ - # ┣┛┛ ┗ ┣┛┛ ┗┛┗┗ ┛┛ - # ┛ + # ┏┓ ┓ ┏┓┓ ┓ + # ┣┫┓┏╋┣┓ ┃ ┣┓┏┓┏┃┏ + # ┛┗┗┻┗┛┗ ┗┛┛┗┗ ┗┛┗ # If the session token is invalid/expired: if kwargs.get("session_info") is None: @@ -167,8 +166,8 @@ async def authorize_sms_client( if inbound_data.smsClient == "nimbusSmsIndia": success = await current_app.sms_controller.set_token_direct( - db_conn = current_app.sql_writer, - mongo_conn = current_app.data_mongo, + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, auth_token = CoreAuthTokenModel( serviceType = "sms", client = inbound_data.smsClient, @@ -183,6 +182,7 @@ async def authorize_sms_client( status = "active", syncFreq = 60 ), + token_notes = {}, session_token = inbound_headers["X-Session-Token"] ) @@ -194,8 +194,8 @@ async def authorize_sms_client( elif inbound_data.smsClient == "savvyBulkSmsKenya": success = await current_app.sms_controller.set_token_direct( - db_conn = current_app.sql_writer, - mongo_conn = current_app.data_mongo, + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, auth_token = CoreAuthTokenModel( serviceType = "sms", client = inbound_data.smsClient, @@ -209,6 +209,7 @@ async def authorize_sms_client( status = "active", syncFreq = 60 ), + token_notes = {}, session_token = inbound_headers["X-Session-Token"] ) diff --git a/api/blueprints/sms/list.py b/api/blueprints/sms/list.py index 6ae47cf..4c6962f 100644 --- a/api/blueprints/sms/list.py +++ b/api/blueprints/sms/list.py @@ -10,7 +10,7 @@ OBJECTIVE: - To receive auth details for various SMS client APIs. + To list SMS messages associated with incoming identifiers. REFERENCES: @@ -63,8 +63,14 @@ from utils_v2.api.async_quart import ( from shared import constants # Data Models: -from models.api.sms.auth import SMSAuthRequestHeaders, SMSAuthRequestData -from models.core.auth_token import CoreAuthTokenModel +from models.core.user import CoreUserInfoModel +from models.api.sms.list import SMSListRequestHeaders, SMSListRequestData + +# Helpers: +from api.helpers.user import token_check + +# To work with MongoDB: +from bson import ObjectId # For asynchronous activities: import asyncio @@ -78,7 +84,7 @@ import asyncio # Related to Quart: -sms_auth_bp = Blueprint("sms_auth", __name__) +sms_list_bp = Blueprint("sms_list", __name__) # ***************************************************************************************************************** @@ -98,7 +104,7 @@ sms_auth_bp = Blueprint("sms_auth", __name__) # ***************************************************************************************************************** -@sms_auth_bp.record_once +@sms_list_bp.record_once def init(blueprint_setup_state): # This gets called when the blueprint is registered. @@ -109,7 +115,7 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- -@sms_auth_bp.route("/auth", methods = ["POST"]) +@sms_list_bp.route("/list", methods = ["GET"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = False, sanitize_data = False) @get_session_info(key = "X-Session-Token", session_coro = "get_session") @@ -117,27 +123,27 @@ def init(blueprint_setup_state): attr_name = "logs_mongo", project = constants.PROJECT_NAME, log_type = constants.MODULE_NAME, - operation = "smsAuthApi", + operation = "smsListApi", log_input = True, log_output = True, - sensitive_keys = ["sessionToken", "X-Session-Token"] + sensitive_keys = ["sessionToken", "X-Session-Token", "tokenKeys"] ) @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @validate_input( - header_validator = lambda x: SMSAuthRequestHeaders(**x).model_dump(), - data_validator = lambda x: SMSAuthRequestData(**x) + header_validator = lambda x: SMSListRequestHeaders(**x).model_dump(), + data_validator = lambda x: SMSListRequestData(**x) ) @handle_cancelled_request() -async def authorize_sms_client( - inbound_headers: dict | SMSAuthRequestHeaders = None, - inbound_data: dict | SMSAuthRequestData = None, +async def list_sms_messages( + inbound_headers: dict | SMSListRequestHeaders = None, + inbound_data: dict | SMSListRequestData = None, inbound_files: dict = None, **kwargs ): """ - Use this when a user wants to register a third-party SMS client with your service. + Use this API when a user wants his SMS messages listed on the screen. :param inbound_headers: auto-extracted by the decorators. :param inbound_data: auto-extracted by the decorators. :param inbound_files: auto-extracted by the decorators. @@ -145,10 +151,9 @@ async def authorize_sms_client( :return: A standard response structure. """ - # ┏┓ - # ┃┃┏┓┏┓┏┓┏┓┏┓┏┏┓┏┏ - # ┣┛┛ ┗ ┣┛┛ ┗┛┗┗ ┛┛ - # ┛ + # ┏┓ ┓ ┏┓┓ ┓ + # ┣┫┓┏╋┣┓ ┃ ┣┓┏┓┏┃┏ + # ┛┗┗┻┗┛┗ ┗┛┛┗┗ ┗┛┗ # If the session token is invalid/expired: if kwargs.get("session_info") is None: @@ -157,62 +162,45 @@ async def authorize_sms_client( http_code = HttpCodes.UNAUTHORIZED ) - # Start by assuming failure: - success = False + # ┏┓ ┓ • ┏┓┓ ┓ + # ┃┃┓┏┏┏┓┏┓┏┓┏┣┓┓┏┓ ┃ ┣┓┏┓┏┃┏ + # ┗┛┗┻┛┛┗┗ ┛ ┛┛┗┗┣┛ ┗┛┛┗┗ ┗┛┗ + # ┛ - # ┏┓ ┳┓• ┓ ┏┓┳┳┓┏┓ ┳ ┓• - # ┣ ┏┓┏┓ ┃┃┓┏┳┓┣┓┓┏┏ ┗┓┃┃┃┗┓ ┃┏┓┏┫┓┏┓ - # ┻ ┗┛┛ ┛┗┗┛┗┗┗┛┗┻┛ ┗┛┛ ┗┗┛ ┻┛┗┗┻┗┗┻ + # Get the tokens from the database: + auth_tokens = await current_app.sms_controller.get_tokens_from_keys( + mongo_data_conn = current_app.data_mongo, + token_keys = inbound_data.tokenKeys, + limit = len(inbound_data.tokenKeys) + ) + token_ids = [ObjectId(t.authTokenId) for t in auth_tokens] - if inbound_data.smsClient == "nimbusSmsIndia": + # Check if these tokens belong to the user claiming ownership: + if not await token_check.is_authorized( + mongo_conn = current_app.data_mongo, + user_info = CoreUserInfoModel(**kwargs["session_info"]), + token_ids = token_ids + ): return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED, + message = "User doesn't have rights over one or more SMS accounts." + ) - success = await current_app.sms_controller.set_token_direct( - sql_conn = current_app.sql_writer, - mongo_data_conn = current_app.data_mongo, - auth_token = CoreAuthTokenModel( - serviceType = "sms", - client = inbound_data.smsClient, - authType = "auth", - auth = inbound_data.auth.model_dump(), - user = kwargs.get("session_info"), - clientUserId = { - "userId": inbound_data.auth.userId, - "senderId": inbound_data.auth.senderId, - "entityId": inbound_data.auth.entityId - }, - status = "active", - syncFreq = 60 - ), - token_notes = {}, - session_token = inbound_headers["X-Session-Token"] - ) + # ┳┓ ┓ • • + # ┃┃┏┓╋┏┓ ┃ ┓┏╋┓┏┓┏┓ + # ┻┛┗┻┗┗┻ ┗┛┗┛┗┗┛┗┗┫ + # ┛ - # ┏┓ ┏┓ ┳┓ ┓┓ ┏┓┳┳┓┏┓ ┓┏┓ - # ┣ ┏┓┏┓ ┗┓┏┓┓┏┓┏┓┏ ┣┫┓┏┃┃┏ ┗┓┃┃┃┗┓ ┃┫ ┏┓┏┓┓┏┏┓ - # ┻ ┗┛┛ ┗┛┗┻┗┛┗┛┗┫ ┻┛┗┻┗┛┗ ┗┛┛ ┗┗┛ ┛┗┛┗ ┛┗┗┫┗┻ - # ┛ ┛ - - elif inbound_data.smsClient == "savvyBulkSmsKenya": - - success = await current_app.sms_controller.set_token_direct( - sql_conn = current_app.sql_writer, - mongo_data_conn = current_app.data_mongo, - auth_token = CoreAuthTokenModel( - serviceType = "sms", - client = inbound_data.smsClient, - authType = "auth", - auth = inbound_data.auth.model_dump(), - user = kwargs.get("session_info"), - clientUserId = { - "partnerId": inbound_data.auth.partnerId, - "shortCode": inbound_data.auth.shortCode - }, - status = "active", - syncFreq = 60 - ), - token_notes = {}, - session_token = inbound_headers["X-Session-Token"] - ) + messages = await current_app.sms_controller.get_messages( + mongo_data_conn = current_app.data_mongo, + token_ids = token_ids, + limit = inbound_data.count, + skip = inbound_data.fromCount, + projection = { + "message.metadata": False, + "message.rawResponse": False + } + ) # ┳┓ # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ @@ -220,13 +208,13 @@ async def authorize_sms_client( # ┛ # Done here: + message_count = len(messages) + success = True if messages is not None and message_count > 0 else False return ResponseModel( status_code = StatusCodes.OK if success else StatusCodes.FAILED, - http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR, - data = { - "client": inbound_data.smsClient, - "authorized": success - } + http_code = HttpCodes.SUCCESS if success else HttpCodes.NOT_FOUND, + data = [m.full for m in messages] if success else None, + message = f"{message_count} SMS message(s) found." ) diff --git a/api/blueprints/sms/send_v2.py b/api/blueprints/sms/send_v2.py index d830a64..ff61e67 100644 --- a/api/blueprints/sms/send_v2.py +++ b/api/blueprints/sms/send_v2.py @@ -6,11 +6,11 @@ DATE: - Monday, 9th Dec., 2024 + Thursday, 19th Dec., 2024 OBJECTIVE: - To receive auth details for various SMS client APIs. + To send SMS messages through various third-party clients. REFERENCES: @@ -45,6 +45,7 @@ from quart import Blueprint, current_app, request # My utils: from utils_v2.string import json from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.api.response import ResponseModel from utils_v2.api.async_quart import ( set_api_version, @@ -59,14 +60,24 @@ from utils_v2.api.async_quart import ( handle_cancelled_request ) -# Data Models: +# Models: from models.core.auth_token import CoreAuthTokenModel from models.api.sms.send import SMSSendRequestHeaders, SMSSendRequestData +from models.api.sms.send import ( + NimbusSMSIndiaMessage, + SavvyBulkSMSKenyaMessage, + SMSSendManyResults +) # Common: from shared import constants -# For asynchronous activities: +# To work with datatypes: +from typing import List, Any + +# To make HTTP requests: +import httpx + import asyncio @@ -109,6 +120,53 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- +async def send_sms_messages( + mongo_data_conn: AsyncMongo, + auth_token: CoreAuthTokenModel, + messages: List[NimbusSMSIndiaMessage | SavvyBulkSMSKenyaMessage], + tags: List[Any] +) -> SMSSendManyResults: + + """ + This function purely tackles message sending. It is not concerned with authorization and security checks. Please + ensure that you perform those checks before coming here. + :param mongo_data_conn: The database connection to use to perform this task. + :param auth_token: The auth token that will be used to send this message. + :param messages: The list of messages to send out. + :param tags: Any tags to attach with these SMS for filtering when querying in the listing service. + :return: The structured result of sending many SMS messages. + """ + + # Start by assuming failure: + results = SMSSendManyResults() + + # Select the right client: + match auth_token.client: + case "nimbusSmsIndia": + results = await current_app.nimbus_sms_india_controller.send_many_sms( + mongo_data_conn = mongo_data_conn, + auth_token = auth_token, + messages = messages, + tags = tags + ) + case "savvyBulkSmsKenya": + results = await current_app.savvy_bulk_sms_kenya_controller.send_many_sms( + mongo_data_conn = mongo_data_conn, + auth_token = auth_token, + messages = messages, + tags = tags + ) + case _: + results.message = "Invalid/unimplemented SMS client." + + # Done here: + return results + + +# --------------------------------------------------------------------------------------------------------------------- + + +@sms_send_bp.route("", methods = ["POST"]) @sms_send_bp.route("/send", methods = ["POST"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = False, sanitize_data = False) @@ -117,10 +175,10 @@ def init(blueprint_setup_state): attr_name = "logs_mongo", project = constants.PROJECT_NAME, log_type = constants.MODULE_NAME, - operation = "smsAuthApi", + operation = "smsSendApi", log_input = True, log_output = True, - sensitive_keys = ["sessionToken", "X-Session-Token", "tokenId"] + sensitive_keys = ["sessionToken", "X-Session-Token", "tokenKey"] ) @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @@ -129,7 +187,7 @@ def init(blueprint_setup_state): data_validator = lambda x: SMSSendRequestData(**x) ) @handle_cancelled_request() -async def send_sms( +async def send_sms_messages_api( inbound_headers: dict | SMSSendRequestHeaders = None, inbound_data: dict | SMSSendRequestData = None, inbound_files: dict = None, @@ -137,7 +195,7 @@ async def send_sms( ): """ - Use this when a user wants to register a third-party SMS client with your service. + Use this API when someone wants to send one or more SMS messages. :param inbound_headers: auto-extracted by the decorators. :param inbound_data: auto-extracted by the decorators. :param inbound_files: auto-extracted by the decorators. @@ -157,13 +215,9 @@ async def send_sms( message = "invalid session" ) - # ┏┓ ┓ ┏┳┓┓ ┏┓┳┳┓┏┓ - # ┗┓┏┓┏┓┏┫ ┃ ┣┓┏┓ ┗┓┃┃┃┗┓ - # ┗┛┗ ┛┗┗┻ ┻ ┛┗┗ ┗┛┛ ┗┗┛ - # Get the token from the token key: - auth_token = await current_app.mail_controller.get_token_from_key( - mongo_conn = current_app.data_mongo, + auth_token = await current_app.sms_controller.get_token_from_key( + mongo_data_conn = current_app.data_mongo, token_key = inbound_data.tokenKey ) if auth_token is None: return ResponseModel( @@ -172,10 +226,12 @@ async def send_sms( message = f"no such token key" ) - # Send the SMS: - client_response = await current_app.sms_controller.send( - mongo_conn = current_app.data_mongo, - http_client = current_app.http_client, + # ┏┓ ┓ ┏┳┓┓ ┏┓┳┳┓┏┓ + # ┗┓┏┓┏┓┏┫ ┃ ┣┓┏┓ ┗┓┃┃┃┗┓ + # ┗┛┗ ┛┗┗┻ ┻ ┛┗┗ ┗┛┛ ┗┗┛ + + sending_results = await send_sms_messages( + mongo_data_conn = current_app.data_mongo, auth_token = auth_token, messages = inbound_data.message, tags = inbound_data.tags @@ -187,16 +243,16 @@ async def send_sms( # ┛ # Done here: - success = True if client_response.successCount else False + success = True if sending_results.successCount else False return ResponseModel( status_code = StatusCodes.OK if success else StatusCodes.FAILED, http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR, data = { - "successCount": client_response.successCount, - "failureCount": client_response.failureCount, - "totalCount": client_response.totalCount, + "successCount": sending_results.successCount, + "failureCount": sending_results.failureCount, + "totalCount": sending_results.totalCount, }, - message = client_response.message + message = sending_results.message ) diff --git a/api/blueprints/sms/tags.py b/api/blueprints/sms/tags.py index ca81bc2..ea8c075 100644 --- a/api/blueprints/sms/tags.py +++ b/api/blueprints/sms/tags.py @@ -10,7 +10,7 @@ OBJECTIVE: - To list SMS messages associated with incoming identifiers. + To update tags on SMS messages. REFERENCES: @@ -36,9 +36,6 @@ # To make sibling directories accessible for imports: import sys - -from bson import ObjectId - sys.path.append(".") sys.path.append("..") @@ -67,11 +64,14 @@ from shared import constants # Data Models: from models.core.user import CoreUserInfoModel -from models.api.sms.list import SMSListRequestHeaders, SMSListRequestData +from models.api.sms.tags import SMSUpdateTagsRequestHeaders, SMSUpdateTagsRequestData # Helpers: from api.helpers.user import token_check +# To work with MongoDB: +from bson import ObjectId + # For asynchronous activities: import asyncio @@ -84,7 +84,7 @@ import asyncio # Related to Quart: -sms_list_bp = Blueprint("sms_list", __name__) +sms_update_tags_bp = Blueprint("sms_upd_tags", __name__) # ***************************************************************************************************************** @@ -104,7 +104,7 @@ sms_list_bp = Blueprint("sms_list", __name__) # ***************************************************************************************************************** -@sms_list_bp.record_once +@sms_update_tags_bp.record_once def init(blueprint_setup_state): # This gets called when the blueprint is registered. @@ -115,7 +115,7 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- -@sms_list_bp.route("/list", methods = ["GET"]) +@sms_update_tags_bp.route("/tags", methods = ["PATCH"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = False, sanitize_data = False) @get_session_info(key = "X-Session-Token", session_coro = "get_session") @@ -123,27 +123,27 @@ def init(blueprint_setup_state): attr_name = "logs_mongo", project = constants.PROJECT_NAME, log_type = constants.MODULE_NAME, - operation = "smsListApi", + operation = "smsUpdTagsApi", log_input = True, log_output = True, - sensitive_keys = ["sessionToken", "X-Session-Token", "tokenKeys"] + sensitive_keys = ["sessionToken", "X-Session-Token", "tokenKey"] ) @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @validate_input( - header_validator = lambda x: SMSListRequestHeaders(**x).model_dump(), - data_validator = lambda x: SMSListRequestData(**x) + header_validator = lambda x: SMSUpdateTagsRequestHeaders(**x).model_dump(), + data_validator = lambda x: SMSUpdateTagsRequestData(**x) ) @handle_cancelled_request() -async def list_sms_messages( - inbound_headers: dict | SMSListRequestHeaders = None, - inbound_data: dict | SMSListRequestData = None, +async def update_sms_tags( + inbound_headers: dict | SMSUpdateTagsRequestHeaders = None, + inbound_data: dict | SMSUpdateTagsRequestData = None, inbound_files: dict = None, **kwargs ): """ - Use this API when a user wants his SMS messages listed on the screen. + Use this APi when the user wants to update the tags on one SMS. :param inbound_headers: auto-extracted by the decorators. :param inbound_data: auto-extracted by the decorators. :param inbound_files: auto-extracted by the decorators. @@ -162,44 +162,44 @@ async def list_sms_messages( http_code = HttpCodes.UNAUTHORIZED ) + # ┏┓ ┓ ┳┳┓ + # ┣ ┏┓╋┏┣┓ ┃┃┃┏┓┏┏┏┓┏┓┏┓ + # ┻ ┗ ┗┗┛┗ ┛ ┗┗ ┛┛┗┻┗┫┗ + # ┛ + + # Get the message: + message = await current_app.sms_controller.get_message( + mongo_data_conn = current_app.data_mongo, + message_id = inbound_data.messageId + ) + # ┏┓ ┓ • ┏┓┓ ┓ # ┃┃┓┏┏┏┓┏┓┏┓┏┣┓┓┏┓ ┃ ┣┓┏┓┏┃┏ # ┗┛┗┻┛┛┗┗ ┛ ┛┛┗┗┣┛ ┗┛┛┗┗ ┗┛┗ # ┛ - # Get the tokens from the database: - auth_tokens = await current_app.sms_controller.get_tokens_from_keys( - mongo_data_conn = current_app.data_mongo, - token_keys = inbound_data.tokenKeys, - limit = len(inbound_data.tokenKeys) - ) - token_ids = [ObjectId(t.authTokenId) for t in auth_tokens] - - # Check if these tokens belong to the user claiming ownership: + # Check if the token(s) belong to the user claiming ownership: if not await token_check.is_authorized( mongo_conn = current_app.data_mongo, user_info = CoreUserInfoModel(**kwargs["session_info"]), - token_ids = token_ids + token_ids = [message.tokenId] ): return ResponseModel( status_code = StatusCodes.FAILED, http_code = HttpCodes.UNAUTHORIZED, - message = "User doesn't have rights over one or more SMS accounts." + message = "User doesn't have rights to this message." ) - # ┳┓ ┓ • • - # ┃┃┏┓╋┏┓ ┃ ┓┏╋┓┏┓┏┓ - # ┻┛┗┻┗┗┻ ┗┛┗┛┗┗┛┗┗┫ - # ┛ + # ┳┳ ┓ ┳┳┓ + # ┃┃┏┓┏┫┏┓╋┏┓ ┃┃┃┏┓┏┏┏┓┏┓┏┓ + # ┗┛┣┛┗┻┗┻┗┗ ┛ ┗┗ ┛┛┗┻┗┫┗ + # ┛ ┛ - messages = await current_app.sms_controller.get_messages( + # Update the message: + success = await current_app.sms_controller.update_tags( mongo_data_conn = current_app.data_mongo, - token_ids = token_ids, - limit = inbound_data.count, - skip = inbound_data.fromCount, - projection = { - "message.metadata": False, - "message.rawResponse": False - } + message_id = inbound_data.messageId, + unset_tags = inbound_data.unsetTags, + set_tags = inbound_data.setTags ) # ┳┓ @@ -208,13 +208,9 @@ async def list_sms_messages( # ┛ # Done here: - message_count = len(messages) - success = True if messages is not None and message_count > 0 else False return ResponseModel( status_code = StatusCodes.OK if success else StatusCodes.FAILED, - http_code = HttpCodes.SUCCESS if success else HttpCodes.NOT_FOUND, - data = [m.full for m in messages] if success else None, - message = f"{message_count} SMS message(s) found." + http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR ) diff --git a/api/main.py b/api/main.py index 1669546..2ffea6e 100644 --- a/api/main.py +++ b/api/main.py @@ -60,19 +60,25 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import AsyncGMailClient +from utils_v2.goog.controllers.gmail.gmail_client import AsyncGMailClient # Core Controller Models: -from controllers.core.message import CoreMessageController +# from controllers.core.message import CoreMessageController from controllers.core.auth_token import CoreAuthTokenController from controllers.core.ai.llm import CoreLLMController from controllers.core.payment import CorePaymentController # API Controller Models: from controllers.api.mail import MailController -from controllers.api.sms import SMSController +# from controllers.api.sms import SMSController from controllers.api.payment import PaymentController +# Controllers V2: +from controllers_v2.core.message import CoreMessageController +from controllers_v2.sms.all_sms import AllSMSController +from controllers_v2.sms.nimbus_sms_india import NimbusSMSIndiaController +from controllers_v2.sms.savvy_bulk_sms_kenya import SavvyBulkSMSKenyaController + # To make REST API calls: import httpx @@ -86,10 +92,13 @@ from api.blueprints.mail.sync.sync_v2 import mail_sync_bp from api.blueprints.mail.retrieve.list import mail_list_bp from api.blueprints.mail.retrieve.get import mail_get_bp from api.blueprints.mail.tags.update import mail_tags_update_bp +from api.blueprints.mail.send.send import mail_send_bp # SMS Blueprints: -from api.blueprints.sms.auth import sms_auth_bp -from api.blueprints.sms.send import sms_send_bp +from api.blueprints.sms.auth_v2 import sms_auth_bp +from api.blueprints.sms.send_v2 import sms_send_bp +from api.blueprints.sms.list import sms_list_bp +from api.blueprints.sms.tags import sms_update_tags_bp # Chat Blueprints: # from api.blueprints.chat.auth import chat_auth_bp @@ -147,10 +156,13 @@ app.register_blueprint(mail_sync_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_list_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_get_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_tags_update_bp, url_prefix = f"/{MODULE_BASE}/mail") +app.register_blueprint(mail_send_bp, url_prefix = f"/{MODULE_BASE}/mail") # SMS Blueprints: app.register_blueprint(sms_auth_bp, url_prefix = f"/{MODULE_BASE}/sms") app.register_blueprint(sms_send_bp, url_prefix = f"/{MODULE_BASE}/sms") +app.register_blueprint(sms_list_bp, url_prefix = f"/{MODULE_BASE}/sms") +app.register_blueprint(sms_update_tags_bp, url_prefix = f"/{MODULE_BASE}/sms") # Chat Blueprints: # app.register_blueprint(chat_auth_bp, url_prefix = f"/{MODULE_BASE}/chat") @@ -389,9 +401,33 @@ async def app_startup(**kwargs): # ┛┗┣┛┻ ┗┛┗┛┛┗┗┛ ┗┛┗┗┗ ┛ ┛ current_app.mail_controller = MailController() - current_app.sms_controller = SMSController() + # current_app.sms_controller = SMSController() current_app.payment_controller = PaymentController() + # ┏┓ ┓┓ ┓┏┏┓ + # ┃ ┏┓┏┓╋┏┓┏┓┃┃┏┓┏┓┏ ┃┃┏┛ + # ┗┛┗┛┛┗┗┛ ┗┛┗┗┗ ┛ ┛ ┗┛┗━ + + # Auth-Token Controller(s): + current_app.core_auth_token_controller = CoreAuthTokenController() + + # SMS Controllers: + current_app.sms_controller = AllSMSController( + http_client = current_app.http_client, + alert_url = current_app.script_data["alerts"]["url"], + debug = enable_debugging + ) + current_app.nimbus_sms_india_controller = NimbusSMSIndiaController( + http_client = current_app.http_client, + alert_url = current_app.script_data["alerts"]["url"], + debug = enable_debugging + ) + current_app.savvy_bulk_sms_kenya_controller = SavvyBulkSMSKenyaController( + http_client = current_app.http_client, + alert_url = current_app.script_data["alerts"]["url"], + debug = enable_debugging + ) + # ┏┓ ┓ ┏┓┓• # ┃ ┏┓┏┓┏┓┏┓┏╋┏┓┏┓┏ ┏┓┏┓┏┫ ┃ ┃┓┏┓┏┓╋┏ # ┗┛┗┛┛┗┛┗┗ ┗┗┗┛┛ ┛ ┗┻┛┗┗┻ ┗┛┗┗┗ ┛┗┗┛ diff --git a/controllers/api/mail.py b/controllers/api/mail.py index 14c1f21..90d1062 100644 --- a/controllers/api/mail.py +++ b/controllers/api/mail.py @@ -50,10 +50,11 @@ from controllers.base import BaseModel from models.core.user import CoreUserInfoModel from models.core.auth_token import CoreAuthTokenModel from models.core.message import CoreMessageModel -from models.api.mail.sync import MailSyncOneResult, MailSyncManyResults +from models.api.mail.sync import MailSyncOneResult, MailSyncManyResults, MailSendOneResult +from models.api.mail.send import MailSendRequestData # Mail Clients: -from utils_v2.goog.gmail.gmail_client import AsyncGMailClient +from utils_v2.goog.controllers.gmail.gmail_client import AsyncGMailClient from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # To work with MongoDB: @@ -121,7 +122,7 @@ class MailController: # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ # For AI Magic through LLMs: - PROMPT_TEMPLATE = [ + RECEIVED_MAIL_PROMPT_TEMPLATE = [ { "role": "system", "content": ( @@ -140,7 +141,17 @@ class MailController: "Example output 3: " "{\"summary\":\"JustDial's marketing message. They're offering a 35% discount to new accounts.\"," "\"senderType\":null}\n" - "Remember to respond only with the JSON string, nothing else. Good luck :)" + "Remember to respond only with the raw JSON string, nothing else. Good luck :)" + ) + } + ] + SENT_MAIL_PROMPT_TEMPLATE = [ + { + "role": "system", + "content": ( + "You're an expert mail summary assistant. That summarizes sent mails in 150 chars or less. " + "The objective of your user is to be able to recollect what a mail they sent was about from a brief " + "summary. Reply in a simple string, no formatting is allowed except emojis. Good luck :)" ) } ] @@ -205,7 +216,8 @@ class MailController: mongo_conn: AsyncMongo, user_info: CoreUserInfoModel, llm: CoreLLMController, - message: CoreMessageModel + message: CoreMessageModel, + prompt_template: List[dict] ) -> LLMOutput: # Extract the text from the message here: @@ -217,7 +229,7 @@ class MailController: mongo_conn = mongo_conn, user_info = user_info, llm_input = LLMInput( - messages = self.PROMPT_TEMPLATE + [ + messages = prompt_template + [ { "role": "human", "content": f"Please summarize this mail: \"\"\"{text}\"\"\"" @@ -409,7 +421,8 @@ class MailController: mongo_conn = mongo_conn, user_info = user_info, llm = llm, - message = mail_message + message = mail_message, + prompt_template = self.PROMPT_TEMPLATE ) ai_json = ai_snippet.json mail_message.aiSnippet = ai_snippet.summary @@ -566,7 +579,7 @@ class MailController: # If we failed to load the authorization tokens: if not auth_token: - sync_results.message = f"no such token key '{token_key}'" + sync_results.message = f"no such token key" return sync_results # ┏┓ ┏┓┳┳┓ •┓ @@ -596,6 +609,90 @@ class MailController: sync_results.message = f"no such mail client '{auth_token.client}'" return sync_results + # ┏┓ ┓ ┳┳┓ + # ┗┓┏┓┏┓┏┫ ┃┃┃┏┓┏┏┏┓┏┓┏┓┏ + # ┗┛┗ ┛┗┗┻ ┛ ┗┗ ┛┛┗┻┗┫┗ ┛ + # ┛ + + async def __send_one_gmail( + self, + db_conn: AsyncMySQL, + mongo_conn: AsyncMongo, + user_info: CoreUserInfoModel, + token_key: ObjectId | str, + inbound_data: MailSendRequestData, + inbound_files: dict, + llm: CoreLLMController = None, + session_token: str = None + ) -> MailSendOneResult: + + # Start by assuming failure: + sync_results = MailSendOneResult() + + # ┏┓ ┏┳┓┓ ┳┳┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┓┏┏╋ ┃ ┣┓┏┓ ┃┃┃┏┓┏┏┏┓┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┛┗┗┗ ┻ ┛┗┗ ┛ ┗┗ ┛┛┗┻┗┫┗ + # ┛ + + gmail_message = None + + async def send_one_mail( + self, + db_conn: AsyncMySQL, + mongo_conn: AsyncMongo, + user_info: CoreUserInfoModel, + token_key: ObjectId | str, + inbound_data: MailSendRequestData, + inbound_files: dict, + llm: CoreLLMController = None, + session_token: str = None + ) -> MailSendOneResult: + + # Start by assuming failure: + sync_results = MailSendOneResult() + + # ┏┓ ┓ ┏┳┓ ┓ + # ┣ ┏┓╋┏┣┓ ┃ ┏┓┃┏┏┓┏┓┏ + # ┻ ┗ ┗┗┛┗ ┻ ┗┛┛┗┗ ┛┗┛ + + # We first load the authorization tokens: + auth_token = await self.get_token_from_key( + mongo_conn = mongo_conn, + token_key = token_key, + ) + + # If we failed to load the authorization tokens: + if not auth_token: + sync_results.message = f"no such token key" + return sync_results + + # ┏┓ ┏┓┳┳┓ •┓ + # ┣ ┏┓┏┓ ┃┓┃┃┃┏┓┓┃ + # ┻ ┗┛┛ ┗┛┛ ┗┗┻┗┗ + + # if auth_token.client == "gmail": + # return await self.__send_one_gmail( + # db_conn = db_conn, + # mongo_conn = mongo_conn, + # user_info = user_info, + # auth_token = auth_token, + # mail_client = current_app.gmail_client, + # llm = llm, + # force_sync = force_sync, + # start_date = start_date, + # end_date = end_date, + # max_count = max_count, + # session_token = session_token, + # ) + + # ┳ ┓• ┓ ┏┓┓• + # ┃┏┓┓┏┏┓┃┓┏┫ ┃ ┃┓┏┓┏┓╋ + # ┻┛┗┗┛┗┻┗┗┗┻ ┗┛┗┗┗ ┛┗┗ + + # If we haven't been able to sync mail due to not entering any 'if' condition: + sync_results.message = f"no such mail client '{auth_token.client}'" + return sync_results + # ┓ • ┏┓ ┏┓ ┳┳┓ # ┃ ┓┏╋ ┣╋ ┃┓┏┓╋ ┃┃┃┏┓┏┏┏┓┏┓┏┓┏ # ┗┛┗┛┗ ┗┻ ┗┛┗ ┗ ┛ ┗┗ ┛┛┗┻┗┫┗ ┛ diff --git a/controllers_v2/core/auth_token.py b/controllers_v2/core/auth_token.py index 0bf52cd..5c99ff4 100644 --- a/controllers_v2/core/auth_token.py +++ b/controllers_v2/core/auth_token.py @@ -31,6 +31,9 @@ # To make sibling directories accessible for imports: import sys + +import httpx + sys.path.append(".") sys.path.append("..") @@ -39,9 +42,10 @@ from utils_v2.string import json from utils_v2.date_time import date_time from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Base model: -from controllers.base import BaseModel +from controllers_v2.core.base import CoreBaseModel # Data models: from models.core.auth_token import CoreAuthTokenModel @@ -90,7 +94,7 @@ from bson import ObjectId # ***************************************************************************************************************** -class CoreAuthTokenController(BaseModel): +class CoreAuthTokenController(CoreBaseModel): # ┏┓┓ ┓┏ # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ @@ -99,10 +103,53 @@ class CoreAuthTokenController(BaseModel): # For MongoDB: AUTH_COLLECTION = "_authTokens" - async def get_token_key( + # ┏┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ + + def __init__( self, - db_conn: AsyncMySQL, - mongo_conn: AsyncMongo, + cache: AsyncRedisCache = None, + http_client: httpx.AsyncClient = None, + alert_url: str = None, + base_filter: dict = None, + debug = True, + debug_prefix = "Core Base (C) | ", + debug_only_errors = True + ): + + """ + This is the core controller for all authorization and token activities. + :param cache: The object to use for caching results from database calls. + :param debug: Whether, or not, you would like to print debugging messages: + :param debug_prefix: The prefix to print with the debugging messages. + :param debug_only_errors: Whether you would like to print only error messages or all messages. + :return: None. + """ + + # Accept the base filter: + self._base_filter = base_filter or {} + + # Invoke the parent's constructor: + CoreBaseModel.__init__( + self, + cache = cache, + http_client = http_client, + alert_url = alert_url, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + + # ┏┓┳┓┳┳┳┓ ┏┓ ┓ ┳┳ ┓ + # ┃ ┣┫┃┃┃┃ ━━ ┃ ┏┓┏┓┏┓╋┏┓ ┏┓┏┓┏┫ ┃┃┏┓┏┫┏┓╋┏┓ + # ┗┛┛┗┗┛┻┛ ┗┛┛ ┗ ┗┻┗┗ ┗┻┛┗┗┻ ┗┛┣┛┗┻┗┻┗┗ + # ┛ + + async def generate_token_key( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, token_notes: dict, session_token: str = None, @@ -111,8 +158,8 @@ class CoreAuthTokenController(BaseModel): """ Stores params from the session info and gives an identifier to use in the authorization URL. Use this when the user requests an authorization URL to link your service to another service (like GMail). - :param db_conn: The database connection (MariaDB) to use to perform the action. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. + :param sql_conn: The database connection (MariaDB) to use to perform the action. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. :param auth_token: An instance of the core auth-token model that holds data in the database. :param token_notes: Any notes to feed into MariaDB with the token identifier. :param session_token: The session token of the user who requested this service. @@ -124,9 +171,9 @@ class CoreAuthTokenController(BaseModel): # Get the identifier from the database if it already exists, else create one. # BE CAREFUL WITH THE KEYS HERE, THEY SHOULD MATCH THE FIELDS OF THE CORE AUTH-TOKEN MODEL: - mongo_json = await mongo_conn.find_one_and_update( + mongo_json = await mongo_data_conn.find_one_and_update( collection = self.AUTH_COLLECTION, - filter = mongo_conn.dict_to_dot_notation({ + filter = mongo_data_conn.dict_to_dot_notation({ "serviceType": auth_token.serviceType, "user": { "entityId": auth_token.user.entityId, @@ -166,7 +213,7 @@ class CoreAuthTokenController(BaseModel): db_json = {} if mongo_json is not None: db_json = await self.call_procedure( - db_conn = db_conn, + sql_conn = sql_conn, proc_name = "entity_integration_save", proc_args = ( auth_token.user.entityId, # ..................................... 'p_entity_id' @@ -187,8 +234,8 @@ class CoreAuthTokenController(BaseModel): async def set_token( self, - db_conn: AsyncMySQL, - mongo_conn: AsyncMongo, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, token_key: ObjectId | str, auth_token: CoreAuthTokenModel, token_notes: dict, @@ -199,9 +246,9 @@ class CoreAuthTokenController(BaseModel): This method is to be called when the end user authorizes your service to connect to his third-party account. For example, when the end user allows you to access his GMail account. USE THIS FOR UPDATING (REFRESHING) TOKENS ALSO. - :param db_conn: The database connection (MariaDB) to use to perform the action. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. - :param token_key: The identifier granted by the 'get_token_key' method. + :param sql_conn: The database connection (MariaDB) to use to perform the action. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. + :param token_key: The identifier granted by the 'generate_token_key' method. :param auth_token: The actual auth/token data to be saved to the database. :param token_notes: Any notes to feed into MariaDB with the token identifier. :param session_token: The session token of the user who requested this service. @@ -216,9 +263,9 @@ class CoreAuthTokenController(BaseModel): # Save the token to MongoDB. # BE CAREFUL WITH THE KEYS HERE, THEY SHOULD MATCH THE FIELDS OF THE CORE AUTH-TOKEN MODEL: - mongo_json = await mongo_conn.find_one_and_update( + mongo_json = await mongo_data_conn.find_one_and_update( collection = self.AUTH_COLLECTION, - filter = mongo_conn.dict_to_dot_notation({ + filter = mongo_data_conn.dict_to_dot_notation({ "key": ObjectId(token_key), "clientUserId": auth_token.clientUserId }), @@ -250,7 +297,7 @@ class CoreAuthTokenController(BaseModel): # Tell MariaDB that the token was saved: if mongo_json is not None: db_json = await self.call_procedure( - db_conn = db_conn, + sql_conn = sql_conn, proc_name = "entity_integration_save", proc_args = ( mongo_json["user"]["entityId"], # ............................... 'p_entity_id' @@ -270,24 +317,82 @@ class CoreAuthTokenController(BaseModel): # Done here: return token_saved + async def set_token_direct( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + auth_token: CoreAuthTokenModel, + token_notes: dict, + session_token: str = None + ) -> bool: + + """ + Some authorizations don't need two steps, but our core system works on the 2-step approach that was developed to + work with Google's GMail OAuth2.0 mechanism. + :param sql_conn: The database connection (MariaDB) to use to perform the action. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. + :param auth_token: The actual auth/token data to be saved to the database. + :param token_notes: Any notes to feed into MariaDB with the token identifier. + :param session_token: The session token of the user who requested this service. + :return: True if saved, False if failed. + """ + + # Start by assuming failure: + success = False + + # Get a token id (and receive its key): + token_key = await self.generate_token_key( + sql_conn = sql_conn, + mongo_data_conn = mongo_data_conn, + auth_token = auth_token, + token_notes = token_notes, + session_token = session_token + ) + + # Immediately save the details against that token id: + success = await self.set_token( + sql_conn = sql_conn, + mongo_data_conn = mongo_data_conn, + token_key = token_key, + auth_token = auth_token, + token_notes = token_notes, + session_token = session_token + ) + + # Done here: + return success + + # ┏┓┳┓┳┳┳┓ ┳┓ • + # ┃ ┣┫┃┃┃┃ ━━ ┣┫┏┓╋┏┓┓┏┓┓┏┏┓ + # ┗┛┛┗┗┛┻┛ ┛┗┗ ┗┛ ┗┗ ┗┛┗ + async def get_token_from_id( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, token_id: ObjectId | str = None, + additional_filter: dict = None ) -> CoreAuthTokenModel | None: """ To retrieve stored tokens from the database. One token at a time. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. :param token_id: The identifier of the document that holds the token's details. + :param additional_filter: Any addition filters to use. :return: The retrieved record that has the token, and information about the service and client if found, else None when there is no matching record. """ + # Prepare the filter: + filter_json = {"_id": ObjectId(token_id)} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # If there is some filtering possible, we fetch the token: - token = await mongo_conn.find_one( + token = await mongo_data_conn.find_one( collection = self.AUTH_COLLECTION, - filter = {"_id": ObjectId(token_id)} + filter = filter_json ) # Done here: @@ -295,22 +400,31 @@ class CoreAuthTokenController(BaseModel): async def get_token_from_key( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, token_key: ObjectId | str = None, + additional_filter: dict = None ) -> CoreAuthTokenModel | None: """ To retrieve stored tokens from the database. One token at a time. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. - :param token_key: The identifier granted by the 'get_token_key' method. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. + :param token_key: The identifier granted by the 'generate_token_key' method. + :param additional_filter: Any addition filters to use. :return: The retrieved record that has the token, and information about the service and client if found, else None when there is no matching record. """ + # Prepare the filter: + filter_json = {"key": ObjectId(token_key)} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # If there is some filtering possible, we fetch the token: - token = await mongo_conn.find_one( + token = await mongo_data_conn.find_one( collection = self.AUTH_COLLECTION, - filter = {"key": ObjectId(token_key)} + filter = filter_json ) # Done here: @@ -318,24 +432,33 @@ class CoreAuthTokenController(BaseModel): async def get_tokens_from_ids( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, token_ids: List[ObjectId | str] = None, - limit: int = 100 + limit: int = 100, + additional_filter: dict = None ) -> List[CoreAuthTokenModel]: """ To retrieve stored tokens from the database. Multiple tokens at a time. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. :param token_ids: The identifier of the document that holds the token's details. :param limit: The max. no. of records to pick. + :param additional_filter: Any addition filters to use. :return: The retrieved record that has the token, and information about the service and client if found, else None when there is no matching record. """ + # Prepare the filter: + filter_json = {"_id": {"$in": [ObjectId(k) for k in token_ids]}} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # If there is some filtering possible, we fetch the token: - tokens = await mongo_conn.find_many( + tokens = await mongo_data_conn.find_many( collection = self.AUTH_COLLECTION, - filter = {"_id": {"$in": [ObjectId(k) for k in token_ids]}}, + filter = filter_json, limit = limit ) @@ -344,30 +467,45 @@ class CoreAuthTokenController(BaseModel): async def get_tokens_from_keys( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, token_keys: List[ObjectId | str] = None, - limit: int = 100 + limit: int = 100, + additional_filter: dict = None ) -> List[CoreAuthTokenModel]: """ To retrieve stored tokens from the database. Multiple tokens at a time. - :param mongo_conn: The database connection (MongoDB) to use to perform the action. - :param token_keys: the identifiers granted by the 'get_token_key' method. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. + :param token_keys: the identifiers granted by the 'generate_token_key' method. :param limit: The max. no. of records to pick. + :param additional_filter: Any addition filters to use. :return: The retrieved record that has the token, and information about the service and client if found, else None when there is no matching record. """ + # Prepare the filter: + filter_json = {"key": {"$in": [ObjectId(k) for k in token_keys]}} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # If there is some filtering possible, we fetch the token: - tokens = await mongo_conn.find_many( + tokens = await mongo_data_conn.find_many( collection = self.AUTH_COLLECTION, - filter = {"key": {"$in": [ObjectId(k) for k in token_keys]}}, + filter = filter_json, limit = limit ) # Done here: return [CoreAuthTokenModel(**token) for token in tokens] + # ┏┓┳┓┳┳┳┓ ┳┓ ┓ + # ┃ ┣┫┃┃┃┃ ━━ ┃┃┏┓┃┏┓╋┏┓ + # ┗┛┛┗┗┛┻┛ ┻┛┗ ┗┗ ┗┗ + + pass + # ***************************************************************************************************************** # ***** **** diff --git a/controllers_v2/core/base.py b/controllers_v2/core/base.py index 6ba2ec3..8311208 100644 --- a/controllers_v2/core/base.py +++ b/controllers_v2/core/base.py @@ -42,9 +42,6 @@ from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache from utils_v2.api.codes import StatusCodes -# For asynchronous activities: -import asyncio - # For debugging: from icecream import IceCreamDebugger @@ -89,7 +86,7 @@ from typing import List # ***************************************************************************************************************** -class BaseModel: +class CoreBaseModel: PREVIEW_LENGTH = 250 @@ -99,12 +96,12 @@ class BaseModel: alert_url = None, http_client = None, debug = True, - debug_prefix = "Model | ", + debug_prefix = "Core Base (C) | ", debug_only_errors = True ): """ - This is the base model. + This is the base controller. :param cache: The object to use for caching results from database calls. :param debug: Whether, or not, you would like to print debugging messages: :param debug_prefix: The prefix to print with the debugging messages. @@ -125,9 +122,6 @@ class BaseModel: if not debug: self._printer.disable() self._debug_only_errors = debug_only_errors - # A semaphore for activities that must absolutely be done one at a time: - self.__exclusive_semaphore = asyncio.Semaphore(1) - # A simple debugging output: self._printer("Model initialized.") @@ -175,7 +169,7 @@ class BaseModel: cache: AsyncRedisCache, cache_key: str, cache_expiry: int, - db_conn: AsyncMySQL, + sql_conn: AsyncMySQL, proc_name: str, proc_args: tuple, retry_count: int = 1, @@ -189,7 +183,7 @@ class BaseModel: :param cache: The caching object to use to set the session in cache memory. :param cache_key: The string to use as the key when caching the response. :param cache_expiry: The no. of seconds after which this information will be deleted from the cache. - :param db_conn: The connection instance to use to call the procedure. + :param sql_conn: The connection instance to use to call the procedure. :param proc_name: The name of the stored procedure that must be called. :param proc_args: The args to be sent to the stored procedure. :param retry_count: The max. number of times to try in case one or more attempts fail. @@ -209,7 +203,7 @@ class BaseModel: # Make the database call: data = await self.call_procedure( - db_conn = db_conn, + sql_conn = sql_conn, proc_name = proc_name, proc_args = proc_args, retry_count = retry_count, @@ -227,7 +221,7 @@ class BaseModel: async def call_procedure( self, - db_conn: AsyncMySQL, + sql_conn: AsyncMySQL, proc_name: str, proc_args: tuple, retry_count: int = 1, @@ -238,7 +232,7 @@ class BaseModel: """ Calls a stored procedure and returns the response as a JSON-like object (dict or list). - :param db_conn: The connection instance to use to call the procedure. + :param sql_conn: The connection instance to use to call the procedure. :param proc_name: The name of the stored procedure that must be called. :param proc_args: The args to be sent to the stored procedure. :param retry_count: The max. number of times to try in case one or more attempts fail. @@ -251,7 +245,7 @@ class BaseModel: """ # Call the stored procedure: - db_json, exception = await db_conn.call_procedure_and_get_json( + db_json, exception = await sql_conn.call_procedure_and_get_json( proc_name, proc_args, retry_count = retry_count, @@ -290,14 +284,14 @@ class BaseModel: async def execute_one( self, - db_conn: AsyncMySQL, + sql_conn: AsyncMySQL, query: str, session_token: str = None ): """ Runs one query and sends an alert if that fails. - :param db_conn: The connection to use to run the query. + :param sql_conn: The connection to use to run the query. :param query: The query to run. :param session_token: A session token to share with the tech module when alerts need to be sent out for any occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the @@ -306,7 +300,7 @@ class BaseModel: """ # Run the query: - rows_affected, db_response, exception = await db_conn.execute_one(query = query, return_exception = True) + rows_affected, db_response, exception = await sql_conn.execute_one(query = query, return_exception = True) # Send an alert out on exceptions: if exception is not None: @@ -329,7 +323,7 @@ class BaseModel: async def execute_many( self, - db_conn: AsyncMySQL, + sql_conn: AsyncMySQL, query: str, data: List[tuple], session_token: str = None @@ -337,7 +331,7 @@ class BaseModel: """ Runs many queries and sends an alert if that fails. - :param db_conn: The connection to use to run the query. + :param sql_conn: The connection to use to run the query. :param query: The query to run. :param data: The data to feed into the query. :param session_token: A session token to share with the tech module when alerts need to be sent out for any @@ -347,7 +341,7 @@ class BaseModel: """ # Run the query: - rows_affected, db_response, exception = await db_conn.execute_many( + rows_affected, db_response, exception = await sql_conn.execute_many( query = query, data = data, return_exception = True diff --git a/controllers_v2/core/message.py b/controllers_v2/core/message.py index 257d7bb..e9d63d0 100644 --- a/controllers_v2/core/message.py +++ b/controllers_v2/core/message.py @@ -6,7 +6,7 @@ DATE: - Thursday, 12th Dec., 2024 + Thursday, 19th Dec., 2024 OBJECTIVE: @@ -34,41 +34,25 @@ import sys sys.path.append(".") sys.path.append("..") -# For Quart: -from quart import current_app - # My async utils: -from utils_v2.string import json -from utils_v2.date_time import date_time -from utils_v2.database.async_mysql_v2 import AsyncMySQL -from utils_v2.database.async_mongo_v2 import AsyncMongo, AsyncMongoStorage +from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache -# Base model: -from controllers.base import BaseModel +# Controllers: +from controllers_v2.core.base import CoreBaseModel +from controllers_v2.core.auth_token import CoreAuthTokenController -# Data models: -from models.core.auth_token import CoreAuthTokenModel +# Models: from models.core.message import CoreMessageModel -from models.core.user import CoreUserInfoModel # To work with MongoDB: from bson import ObjectId -from pymongo import InsertOne, UpdateOne, ReplaceOne # To work with datatypes: -from typing import Literal, List, Dict, Any +from typing import List -# To make deep-copies: -import copy - -# To work with base-64 encoding: -import base64 - -# To work with date and time: -import datetime - -# For asynchronous activities: -import asyncio +# To make HTTP requests: +import httpx # ***************************************************************************************************************** @@ -108,7 +92,7 @@ import asyncio # ***************************************************************************************************************** -class CoreMessageController(BaseModel): +class CoreMessageController(CoreAuthTokenController): # ┏┓┓ ┓┏ # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ @@ -117,45 +101,92 @@ class CoreMessageController(BaseModel): # For MongoDB: MESSAGES_COLLECTION = "_messages" + # ┏┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ + + def __init__( + self, + cache: AsyncRedisCache = None, + http_client: httpx.AsyncClient = None, + alert_url: str = None, + base_filter: dict = None, + debug: bool = True, + debug_prefix: str = "Message (C) | ", + debug_only_errors: bool = True + ): + + """ + This is the foundational controller of all message controllers. You must structure individual message + controllers through this structure. Individual message controllers would be for things like mails, SMS messages, + chat app messages, etc. + :param cache: The object to use for caching results from database calls. + :param http_client: The HTTP client + :param base_filter: The basic filter that will be applied to all fetching/updating queries. WARNING: THE BASE + FILTER WILL ALWAYS BE APPLIED AUTOMATICALLY. SET THIS UP WISELY. + :param debug: Whether, or not, you would like to print debugging messages: + :param debug_prefix: The prefix to print with the debugging messages. + :param debug_only_errors: Whether you would like to print only error messages or all messages. + :return: None. + """ + + # Accept the base filter: + self._base_filter = base_filter or {} + + # Invoke the parent's constructor: + CoreAuthTokenController.__init__( + self, + cache = cache, + http_client = http_client, + alert_url = alert_url, + base_filter = base_filter, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + # ┏┓┳┓┳┳┳┓ ┏┓ # ┃ ┣┫┃┃┃┃ ━━ ┃ ┏┓┏┓┏┓╋┏┓ # ┗┛┛┗┗┛┻┛ ┗┛┛ ┗ ┗┻┗┗ - async def insert( + async def save_one_message( self, - mongo_conn: AsyncMongo, - message: CoreMessageModel + mongo_data_conn: AsyncMongo, + message: CoreMessageModel, + session = None ) -> ObjectId: """ Simply insert one message document into the database. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param message: The message to save into the database. + :param session: In case you need to perform this operation as a transaction, pass a session here. :return: The object id of the inserted document. """ # Simply insert the document: - return await mongo_conn.insert_one( + return await mongo_data_conn.insert_one( collection = self.MESSAGES_COLLECTION, - document = message, - raise_exception = True + document = message.model_dump(), + raise_exception = True, + session = session ) - async def bulk_write( + async def bulk_operate_messages( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, mongo_operations: list ) -> int: """ Needed in cases like forcing re-sync of mails where you need to perform actions like bulk replacements of existing documents. Not recommended to use. Please use very carefully to ensure document integrity. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param mongo_operations: The list operations that are supported by MongoDB's Bulk Write system. :return: The no. of documents affected. """ - return await mongo_conn.bulk_write( + return await mongo_data_conn.bulk_write( collection = self.MESSAGES_COLLECTION, requests = mongo_operations, raise_exception = True @@ -167,29 +198,35 @@ class CoreMessageController(BaseModel): async def count_messages( self, - mongo_conn: AsyncMongo, - token_ids: List[ObjectId | str], + mongo_data_conn: AsyncMongo, + token_ids: List[ObjectId | str] = None, additional_filter: dict = None ) -> int: """ Just counts the no. of messages that match a given set of conditions. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param token_ids: The token ids of the accounts from which these messages must be fetched. :param additional_filter: Any addition filters to use. :return: The no. of messages that match the given conditions. """ + # We cannot allow counting without any filter whatsoever: + if token_ids is None and not additional_filter: + raise ValueError("Cannot operate without some filter.") + # Prepare the filter: - if not isinstance(token_ids, list): token_ids = [token_ids] - token_ids = [ObjectId(t) for t in token_ids] - filter_json = {"tokenId": {"$in": token_ids}} + filter_json = {} + if token_ids is not None: + if not isinstance(token_ids, list): token_ids = [token_ids] + filter_json["tokenId"] = {"$in": token_ids} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v if additional_filter: - for k, v in additional_filter.items(): - filter_json[k] = v + for k, v in additional_filter.items(): filter_json[k] = v # Get the count of the documents that match the criteria: - count = await mongo_conn.count( + count = await mongo_data_conn.count( collection = self.MESSAGES_COLLECTION, filter = filter_json, raise_exception = True @@ -198,42 +235,51 @@ class CoreMessageController(BaseModel): # Done here: return count - async def get_previews( + async def get_message_previews( self, - mongo_conn: AsyncMongo, - token_ids: List[ObjectId | str], + mongo_data_conn: AsyncMongo, + token_ids: List[ObjectId | str] = None, limit: int = 100, skip: int = 0, - additional_filter: dict = None + additional_filter: dict = None, + projection: dict = None ) -> List[CoreMessageModel] | None: """ Fetches many messages in one call, but leaves out the full payloads. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param token_ids: The token ids of the accounts from which these messages must be fetched. :param limit: The max. no. of messages to retrieve in this call. :param skip: The no. of initial messages to skip. Useful for pagination. :param additional_filter: Any addition filters to use. + :param projection: To decide what is picked from each document. WARNING: THIS MAY BREAK THE BEHAVIOUR OF THE + CORE MESSAGE MODEL. USE CAREFULLY. :return: The list of messages (as the message model). This list can be empty. """ + # We cannot allow counting without any filter whatsoever: + if token_ids is None and not additional_filter: + raise ValueError("Cannot operate without some filter.") + # Prepare the filter: - if not isinstance(token_ids, list): token_ids = [token_ids] - token_ids = [ObjectId(t) for t in token_ids] - filter_json = {"tokenId": {"$in": token_ids}} + filter_json = {} + if token_ids is not None: + if not isinstance(token_ids, list): token_ids = [token_ids] + filter_json["tokenId"] = {"$in": token_ids} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v if additional_filter: - for k, v in additional_filter.items(): - filter_json[k] = v + for k, v in additional_filter.items(): filter_json[k] = v # We fetch the messages that are identified by a specific token id, # with the specified fetching limits, while enforcing the sorting condition: - records = await mongo_conn.find_many( + records = await mongo_data_conn.find_many( collection = self.MESSAGES_COLLECTION, filter = filter_json, limit = limit, skip = skip, sort = {"ts": -1}, - projection = { + projection = projection or { "_id": True, "ts": True, "syncTs": True, @@ -260,39 +306,49 @@ class CoreMessageController(BaseModel): async def get_messages( self, - mongo_conn: AsyncMongo, - token_ids: List[ObjectId | str], + mongo_data_conn: AsyncMongo, + token_ids: List[ObjectId | str] = None, limit: int = 100, skip: int = 0, - additional_filter: dict = None + additional_filter: dict = None, + projection: dict = None ) -> List[CoreMessageModel] | None: """ Fetches many full messages in one call. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param token_ids: The token ids of the accounts from which these messages must be fetched. :param limit: The max. no. of messages to retrieve in this call. :param skip: The no. of initial messages to skip. Useful for pagination. :param additional_filter: Any addition filters to use. + :param projection: To decide what is picked from each document. WARNING: THIS MAY BREAK THE BEHAVIOUR OF THE + CORE MESSAGE MODEL. USE CAREFULLY. :return: The list of messages (as the message model). This list can be empty. """ + # We cannot allow counting without any filter whatsoever: + if token_ids is None and not additional_filter: + raise ValueError("Cannot operate without some filter.") + # Prepare the filter: - if not isinstance(token_ids, list): token_ids = [token_ids] - token_ids = [ObjectId(t) for t in token_ids] - filter_json = {"tokenId": {"$in": token_ids}} + filter_json = {} + if token_ids is not None: + if not isinstance(token_ids, list): token_ids = [token_ids] + filter_json["tokenId"] = {"$in": token_ids} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v if additional_filter: - for k, v in additional_filter.items(): - filter_json[k] = v + for k, v in additional_filter.items(): filter_json[k] = v # We fetch the messages that are identified by a specific token id, # with the specified fetching limits, while enforcing the sorting condition: - records = await mongo_conn.find_many( + records = await mongo_data_conn.find_many( collection = self.MESSAGES_COLLECTION, filter = filter_json, limit = limit, skip = skip, sort = {"ts": -1}, + projection = projection, raise_exception = True ) @@ -301,21 +357,34 @@ class CoreMessageController(BaseModel): async def get_message( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, message_id: ObjectId | str, + additional_filter: dict = None, + projection: dict = None ) -> CoreMessageModel | None: """ Gets one message if you know its message id. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param message_id: The id of the message that needs to be read. + :param additional_filter: Any addition filters to use. + :param projection: To decide what is picked from each document. WARNING: THIS MAY BREAK THE BEHAVIOUR OF THE + CORE MESSAGE MODEL. USE CAREFULLY. :return: The contents of that one message in a structured format. """ + # Start by preparing the filter: + filter_json = {"_id": ObjectId(message_id)} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # We fetch the whole payload of that one message: - record = await mongo_conn.find_one( + record = await mongo_data_conn.find_one( collection = self.MESSAGES_COLLECTION, - filter = {"_id": ObjectId(message_id)}, + filter = filter_json, + projection = projection, raise_exception = True ) @@ -336,25 +405,34 @@ class CoreMessageController(BaseModel): async def update_tags( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, message_id: ObjectId | str, unset_tags: List[str] = None, - set_tags: List[str] = None + set_tags: List[str] = None, + additional_filter: dict = None ) -> bool: """ Updates the tags on one message. The tags to remove are processed first, the ones to add are processed later. - :param mongo_conn: The instance of the database connector to use for the operation. + :param mongo_data_conn: The instance of the database connector to use for the operation. :param message_id: The id of the message that needs to be read. :param unset_tags: The tags to remove from the message. :param set_tags: The tags to add to the message. + :param additional_filter: Any addition filters to use. :return: True if the update was successful, else False. """ + # Start by preparing the filter: + filter_json = {"_id": ObjectId(message_id)} + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + if additional_filter: + for k, v in additional_filter.items(): filter_json[k] = v + # Update the tags: - return await mongo_conn.update_one( + return await mongo_data_conn.update_one( collection = self.MESSAGES_COLLECTION, - filter = {"_id": ObjectId(message_id)}, + filter = filter_json, update = [{ "$set": { "tags": { diff --git a/controllers_v2/sms/all_sms.py b/controllers_v2/sms/all_sms.py index 90a6690..ff41a66 100644 --- a/controllers_v2/sms/all_sms.py +++ b/controllers_v2/sms/all_sms.py @@ -10,8 +10,7 @@ OBJECTIVE: - To handle all SMS related behaviour for Nimbus It's service from one place. - This service is for India only. + To handle all SMS related behaviour for all third-party clients from one place. REFERENCES: @@ -103,7 +102,7 @@ import asyncio # ***************************************************************************************************************** -class NimbusSMSIndiaController(SMSController): +class AllSMSController(SMSController): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -115,7 +114,7 @@ class NimbusSMSIndiaController(SMSController): http_client: httpx.AsyncClient = None, alert_url: str = None, debug: bool = True, - debug_prefix: str = "Nimbus SMS (C) | ", + debug_prefix: str = "All SMS (C) | ", debug_only_errors: bool = True ): @@ -135,7 +134,7 @@ class NimbusSMSIndiaController(SMSController): cache = cache, alert_url = alert_url, http_client = http_client, - base_filter = {"client": "nimbusSmsIndia"}, + base_filter = None, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors @@ -156,7 +155,7 @@ class NimbusSMSIndiaController(SMSController): ) -> SMSSendOneResult: """ - Use this to send one SMS. There are just 2 steps here - send the SMS, and store its details in the database. + Just a placeholder to match the abstract parent. :param mongo_data_conn: The database connection to use to perform this task. :param auth_token: The auth token that will be used to send this message. :param client: The third-party SMS client to use to send this message. @@ -165,48 +164,7 @@ class NimbusSMSIndiaController(SMSController): :return: The structured result of sending one message. """ - # Send the SMS: - client_response = await client.send_sms( - recipient_number = message.recipientNo, - message = message.text, - template_id = message.templateId - ) - - # Convert the format of the SMS client's response to the core message model. - sent_message_model = CoreMessageModel( - ts = client_response.ts, - syncTs = date_time.get_current_utc_date_time(as_string = False), - tokenId = auth_token.authTokenId, - serviceType = auth_token.serviceType, - client = auth_token.client, - clientMessageId = client_response.messageId, - clientThreadId = message.recipientNo, - isSent = True, - isBroadcast = False, - sentSuccessfully = client_response.success, - sender = None, - recipient = message.recipientNo, - chat = None, - message = client_response.model_dump(), - snippet = message.text, - aiSnippet = None, - tags = list(set(tags + ["SMS", "Nimbus SMS", "India"])) - ) - - # Save the result to the database: - message_id = await self.save_one_message( - mongo_data_conn = mongo_data_conn, - message = sent_message_model - ) - self._printer(message_id, client_response.success) - - # Done here: - success = True if client_response.success and message_id else False - return SMSSendOneResult( - success = success, - message = "SMS sent successfully." if client_response.success else "SMS sending failed.", - smsMessage = message - ) + raise NotImplementedError async def send_many_sms( self, @@ -217,7 +175,7 @@ class NimbusSMSIndiaController(SMSController): ) -> SMSSendManyResults: """ - Use this to send multiple SMS messages. This method just calls the individual SMS sending method for every + Just a placeholder to match the abstract parent. individual message, and then aggregates the results. :param mongo_data_conn: The database connection to use to perform this task. :param auth_token: The auth token that will be used to send this message. @@ -227,41 +185,7 @@ class NimbusSMSIndiaController(SMSController): :return: The structured result of sending many SMS messages. """ - # Start with a blank variable: - cumulative_results = SMSSendManyResults() - - # Make the client from the auth-token: - client = AsyncNimbusSMS( - entity_id = auth_token.auth["entityId"], - sender_id = auth_token.auth["senderId"], - user_id = auth_token.auth["userId"], - api_key = auth_token.auth["apiKey"], - http_client = self._http_client - ) - - # Create and fire all the SMS-sending tasks: - tasks = [ - self.send_one_sms( - mongo_data_conn = mongo_data_conn, - auth_token = auth_token, - client = client, - message = message, - tags = tags - ) - for message in messages - ] - individual_results = await asyncio.gather(*tasks) - - # Prepare the final result: - for result in individual_results: - if result.success: cumulative_results.successCount += 1 - else: cumulative_results.failureCount += 1 - cumulative_results.totalCount += 1 - cumulative_results.smsMessages.append(result.smsMessage) - cumulative_results.message = f"{cumulative_results.successCount}/{cumulative_results.totalCount} SMS sent." - - # Done here: - return cumulative_results + raise NotImplementedError # ***************************************************************************************************************** diff --git a/controllers_v2/sms/base.py b/controllers_v2/sms/base.py index 1b4fae9..6689e1e 100644 --- a/controllers_v2/sms/base.py +++ b/controllers_v2/sms/base.py @@ -6,7 +6,7 @@ DATE: - Friday, 13th Dec., 2024 + Thursday, 19th Dec., 2024 OBJECTIVE: @@ -35,46 +35,34 @@ import sys sys.path.append(".") sys.path.append("..") -# For Quart: -from quart import current_app - # My async utils: -from utils_v2.string import json -from utils_v2.date_time import date_time -from utils_v2.database.async_mysql_v2 import AsyncMySQL -from utils_v2.database.async_mongo_v2 import AsyncMongo, AsyncMongoStorage +from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache -# Base model: -from controllers.base import BaseModel +# Controllers: +from controllers_v2.core.message import CoreMessageController -# Data models: -from models.core.user import CoreUserInfoModel +# Models: from models.core.auth_token import CoreAuthTokenModel -from models.core.message import CoreMessageModel from models.api.sms.send import ( - SMSSendRequestData, NimbusSMSIndiaMessage, SavvyBulkSMSKenyaMessage, + SMSSendOneResult, SMSSendManyResults ) -# SMS Clients: +# SMS clients: from utils_v2.sms.india.nimbus.controllers.async_nimbus import AsyncNimbusSMS from utils_v2.sms.kenya.savvy_bulk_sms.controllers.async_savvy_bulk_sms import AsyncSavvyBulkSMS -from utils_v2.sms.models.sms_message import SentSMSMessageModel - -# To work with MongoDB: -from bson import ObjectId -from pymongo import InsertOne # To work with datatypes: -from typing import Literal, List, Dict, Any +from typing import List, Any -# To make API calls: +# To make HTTP requests: import httpx -# For asynchronous activities: -import asyncio +# To make abstract classes: +from abc import ABC, abstractmethod # ***************************************************************************************************************** @@ -114,299 +102,100 @@ import asyncio # ***************************************************************************************************************** -class SMSController: +class SMSController(CoreMessageController, ABC): - # ┏┓┓ ┓┏ - # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ - # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ + # ┏┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ - pass - - # ┓┏ ┓ - # ┣┫┏┓┃┏┓┏┓┏┓┏ - # ┛┗┗ ┗┣┛┗ ┛ ┛ - # ┛ - - pass - - # ┏┓ ┓ - # ┣┫┓┏╋┣┓ - # ┛┗┗┻┗┛┗ - - @staticmethod - async def set_token_direct( - db_conn: AsyncMySQL, - mongo_conn: AsyncMongo, - auth_token: CoreAuthTokenModel, - session_token: str = None - ) -> bool: - - # Start by assuming failure: - success = False - - # Get a token id: - token_key = await current_app.core_auth_token_controller.get_token_key( - db_conn = db_conn, - mongo_conn = mongo_conn, - auth_token = auth_token, - token_notes = {}, - session_token = session_token - ) - - # Immediately save the details against that token id: - success = await current_app.core_auth_token_controller.set_token( - db_conn = db_conn, - mongo_conn = mongo_conn, - token_key = token_key, - auth_token = auth_token, - token_notes = {}, - session_token = session_token - ) - - # Done here: - return success - - @staticmethod - async def get_token( - mongo_conn: AsyncMongo, - token_key: ObjectId | str = None, - ) -> CoreAuthTokenModel | None: - - # Simply call the core model: - return await current_app.core_auth_token_controller.get_token_from_key( - mongo_conn = mongo_conn, - token_key = token_key - ) - - # ┏┓ ┓ - # ┗┓┏┓┏┓┏┫ - # ┗┛┗ ┛┗┗┻ - - @staticmethod - async def __send_from_nimbus_sms_india( - http_client: httpx.AsyncClient, - auth_token: CoreAuthTokenModel, - messages: List[NimbusSMSIndiaMessage], - tags: List[Any] - ) -> SMSSendManyResults: - - # Start with a blank variable: - send_results = SMSSendManyResults() - - # Initialize the third-party client: - client = AsyncNimbusSMS( - entity_id = auth_token.auth["entityId"], - sender_id = auth_token.auth["senderId"], - user_id = auth_token.auth["userId"], - api_key = auth_token.auth["apiKey"], - http_client = http_client - ) - - # Iterate over all the messages you need to send: - for message in messages: - - # Send the SMS and return the response: - client_response = await client.send_sms( - recipient_number = message.recipientNo, - message = message.text, - template_id = message.templateId - ) - - # Note down the results: - send_results.totalCount += 1 - if client_response.success: send_results.successCount += 1 - else: send_results.failureCount += 1 - send_results.smsMessages.append(CoreMessageModel( - ts = client_response.ts, - syncTs = date_time.get_current_utc_date_time(as_string = False), - tokenId = auth_token.authTokenId, - serviceType = auth_token.serviceType, - client = auth_token.client, - clientMessageId = client_response.messageId, - clientThreadId = message.recipientNo, - isSent = True, - isBroadcast = False, - sentSuccessfully = client_response.success, - sender = None, - recipient = message.recipientNo, - chat = None, - message = client_response.model_dump(), - snippet = message.text, - aiSnippet = None, - tags = list(set(tags + ["SMS", "Nimbus SMS", "India"])) - )) - - # Done here: - return send_results - - @staticmethod - async def __send_from_savvy_bulk_sms_kenya( - http_client: httpx.AsyncClient, - auth_token: CoreAuthTokenModel, - messages: List[SavvyBulkSMSKenyaMessage], - tags: List[Any] - ) -> SMSSendManyResults: - - # Start with a blank variable: - send_results = SMSSendManyResults() - - # Initialize the third-party client: - client = AsyncSavvyBulkSMS( - partner_id = auth_token.auth["partnerId"], - short_code = auth_token.auth["shortCode"], - api_key = auth_token.auth["apiKey"], - http_client = http_client - ) - - # Iterate over all the messages you need to send: - for message in messages: - - # Send the SMS and return the response: - client_response = await client.send_sms( - recipient_number = message.recipientNo, - message = message.text - ) - - # Note down the results: - send_results.totalCount += 1 - if client_response.success: send_results.successCount += 1 - else: send_results.failureCount += 1 - send_results.smsMessages.append(CoreMessageModel( - ts = client_response.ts, - syncTs = date_time.get_current_utc_date_time(as_string = False), - tokenId = auth_token.authTokenId, - serviceType = auth_token.serviceType, - client = auth_token.client, - clientMessageId = client_response.messageId, - clientThreadId = message.recipientNo, - isSent = True, - isBroadcast = False, - sentSuccessfully = client_response.success, - sender = None, - recipient = message.recipientNo, - chat = None, - message = client_response.model_dump(), - snippet = message.text, - aiSnippet = None, - tags = list(set(tags + ["SMS", "Savvy Bulk SMS", "Kenya"])) - )) - - # Done here: - return send_results - - async def send( + def __init__( self, - mongo_conn: AsyncMongo, - http_client: httpx.AsyncClient, + cache: AsyncRedisCache = None, + http_client: httpx.AsyncClient = None, + alert_url: str = None, + base_filter: dict = None, + debug: bool = True, + debug_prefix: str = "SMS (C) | ", + debug_only_errors: bool = True + ): + + """ + This is the foundational controller for all SMS services. This is built on top of the core message controller, + and, in turn, all individual SMS client controllers must be built on top of this. + :param cache: The object to use for caching results from database calls. + :param http_client: The HTTP client + :param base_filter: The basic filter that will be applied to all fetching/updating queries. WARNING: THE BASE + FILTER WILL ALWAYS BE APPLIED AUTOMATICALLY. SET THIS UP WISELY. + :param debug: Whether, or not, you would like to print debugging messages: + :param debug_prefix: The prefix to print with the debugging messages. + :param debug_only_errors: Whether you would like to print only error messages or all messages. + :return: None. + """ + + # Prepare the combined base filter: + sms_filter = {} + for k, v in (base_filter or {}).items(): sms_filter[k] = v + sms_filter["serviceType"] = "sms" + + # Invoke the parent's constructor: + CoreMessageController.__init__( + self, + cache = cache, + alert_url = alert_url, + http_client = http_client, + base_filter = sms_filter, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + + # ┏┓┳┳┓┏┓ ┏┓ ┓• + # ┗┓┃┃┃┗┓ ┗┓┏┓┏┓┏┫┓┏┓┏┓ + # ┗┛┛ ┗┗┛ ┗┛┗ ┛┗┗┻┗┛┗┗┫ + # ┛ + + async def send_one_sms( + self, + mongo_data_conn: AsyncMongo, + auth_token: CoreAuthTokenModel, + client: AsyncNimbusSMS | AsyncSavvyBulkSMS, + message: NimbusSMSIndiaMessage, + tags: List[Any] + ) -> SMSSendOneResult: + + """ + To send one SMS message through the third-party client. + :param mongo_data_conn: The database connection to use to perform this task. + :param auth_token: The auth token that will be used to send this message. + :param client: The third-party SMS client to use to send this message. + :param message: The actual message that needs to be sent. + :param tags: Any tags to attach with this SMS for filtering when querying in the listing service. + :return: The structured result of sending one message. + """ + + pass + + @abstractmethod + async def send_many_sms( + self, + mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, messages: List[NimbusSMSIndiaMessage | SavvyBulkSMSKenyaMessage], tags: List[Any] ) -> SMSSendManyResults: - # Start by assuming failure: - send_results = SMSSendManyResults() + """ + To send multiple SMS messages through the third-party client. + individual message, and then aggregates the results. + :param mongo_data_conn: The database connection to use to perform this task. + :param auth_token: The auth token that will be used to send this message. + :param messages: The list of messages to send out. + :param tags: Any tags to attach with these SMS for filtering when querying in the listing service. The same tags + will be applied to all messages. Do not call this method if you need to have different tags for all of them. + :return: The structured result of sending many SMS messages. + """ - # Now we route the message to the appropriate client: - match auth_token.client: - case "nimbusSmsIndia": - send_results = await self.__send_from_nimbus_sms_india( - http_client = http_client, - auth_token = auth_token, - messages = messages, - tags = tags - ) - case "savvyBulkSmsKenya": - send_results = await self.__send_from_savvy_bulk_sms_kenya( - http_client = http_client, - auth_token = auth_token, - messages = messages, - tags = tags - ) - case _: - send_results.message = f"invalid client {auth_token.client}" - - # Save the results to MongoDB: - tasks = [] - for sms in send_results.smsMessages: - message_json = sms.model_dump() - message_json.pop("_id", None) - tasks.append(current_app.core_message_controller.insert( - mongo_conn = mongo_conn, - message = message_json - )) - results = await asyncio.gather(*tasks) - - # Done here: - send_results.message = f"{send_results.successCount}/{send_results.totalCount} message(s) sent" - return send_results - - # ┓ • ┏┓ ┏┓ ┳┳┓ - # ┃ ┓┏╋ ┣╋ ┃┓┏┓╋ ┃┃┃┏┓┏┏┏┓┏┓┏┓┏ - # ┗┛┗┛┗ ┗┻ ┗┛┗ ┗ ┛ ┗┗ ┛┛┗┻┗┫┗ ┛ - # ┛ - - # These are simply for retrieving sms messages. - # You need to already have them saved to the database. - - # @staticmethod - # async def list_messages( - # mongo_conn: AsyncMongo, - # token_ids: List[ObjectId | str], - # limit: int = 100, - # skip: int = 0, - # additional_filter: dict = None - # ) -> List[CoreMessageModel] | None: - # - # # regardless of what additional filter is provided from outside, - # # we add a mail-selecting filter here: - # if additional_filter is None: additional_filter = {} - # additional_filter["serviceType"] = "sms" - # - # # Simply call the core model: - # return await current_app.core_message_controller.get_message( - # mongo_conn = mongo_conn, - # token_ids = token_ids, - # limit = limit, - # skip = skip, - # additional_filter = additional_filter - # ) - # - # @staticmethod - # async def get_one_mail( - # mongo_conn: AsyncMongo, - # token_id: ObjectId | str, - # message_id: ObjectId | str - # ) -> CoreMessageModel | None: - # - # # Simply call the core model: - # return await current_app.core_message_controller.get_message( - # mongo_conn = mongo_conn, - # token_id = token_id, - # message_id = message_id - # ) - - # ┳┳ ┓ - # ┃┃┏┓┏┫┏┓╋┏┓ - # ┗┛┣┛┗┻┗┻┗┗ - # ┛ - - @staticmethod - async def update_tags( - mongo_conn: AsyncMongo, - token_id: ObjectId | str, - message_id: ObjectId | str, - unset_tags: List[str] = None, - set_tags: List[str] = None - ) -> bool: - - # Simply call the core model: - return await current_app.core_message_controller.update_tags( - mongo_conn = mongo_conn, - token_id = token_id, - message_id = message_id, - unset_tags = unset_tags, - set_tags = set_tags - ) + pass # ***************************************************************************************************************** @@ -419,16 +208,3 @@ class SMSController: if __name__ == "__main__": pass - - # from utils_v2.string import json - # - # file_options = [ - # r"/home/developer/Downloads/recursive parts parse - 20241210.json", - # r"/home/developer/Downloads/recursive parts parse (no attachment) - 20241210.json", - # ] - # - # raw_mail_json = json.from_file(file_options[1]) - # print("FROM FILE:", json.to_string(raw_mail_json["payload"])) - # print("\n\n---------\n\n") - # mail_controller = MailController() - # print(json.to_string(mail_controller.drop_attachments(raw_mail_json["payload"]))) diff --git a/controllers_v2/sms/nimbus_sms_india.py b/controllers_v2/sms/nimbus_sms_india.py index 8965ae0..44e376f 100644 --- a/controllers_v2/sms/nimbus_sms_india.py +++ b/controllers_v2/sms/nimbus_sms_india.py @@ -6,11 +6,12 @@ DATE: - Friday, 13th Dec., 2024 + Thursday, 19th Dec., 2024 OBJECTIVE: - To handle all SMS related behaviour from one place. + To handle all SMS related behaviour for Nimbus It's service from one place. + This service is for India only. REFERENCES: @@ -35,55 +36,35 @@ import sys sys.path.append(".") sys.path.append("..") -# For Quart: -from quart import current_app - # My async utils: -from utils_v2.string import json from utils_v2.date_time import date_time -from utils_v2.database.async_mysql_v2 import AsyncMySQL -from utils_v2.database.async_mongo_v2 import AsyncMongo, AsyncMongoStorage +from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.core.message import CoreMessageController +from controllers_v2.sms.base import SMSController # Models: from models.core.auth_token import CoreAuthTokenModel from models.core.message import CoreMessageModel -from models.core.user import CoreUserInfoModel from models.api.sms.send import ( - SMSSendRequestData, NimbusSMSIndiaMessage, - SavvyBulkSMSKenyaMessage, + SMSSendOneResult, SMSSendManyResults ) -# To work with MongoDB: -from bson import ObjectId -from pymongo import InsertOne, UpdateOne, ReplaceOne +# SMS Clients: +from utils_v2.sms.india.nimbus.controllers.async_nimbus import AsyncNimbusSMS # To work with datatypes: -from typing import Literal, List, Dict, Any +from typing import List, Any # To make HTTP requests: import httpx -# To make deep-copies: -import copy - -# To work with base-64 encoding: -import base64 - -# To work with date and time: -import datetime - # For asynchronous activities: import asyncio -# To make abstract classes: -from abc import ABC, abstractmethod - # ***************************************************************************************************************** # ***** **** @@ -122,7 +103,7 @@ from abc import ABC, abstractmethod # ***************************************************************************************************************** -class SMSController(CoreMessageController, ABC): +class NimbusSMSIndiaController(SMSController): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -133,9 +114,8 @@ class SMSController(CoreMessageController, ABC): cache: AsyncRedisCache = None, http_client: httpx.AsyncClient = None, alert_url: str = None, - base_filter: dict = None, debug: bool = True, - debug_prefix: str = "SMS (C) | ", + debug_prefix: str = "Nimbus SMS (C) | ", debug_only_errors: bool = True ): @@ -144,25 +124,18 @@ class SMSController(CoreMessageController, ABC): and, in turn, all individual SMS client controllers must be built on top of this. :param cache: The object to use for caching results from database calls. :param http_client: The HTTP client - :param base_filter: The basic filter that will be applied to all fetching/updating queries. WARNING: THE BASE - FILTER WILL ALWAYS BE APPLIED AUTOMATICALLY. SET THIS UP WISELY. :param debug: Whether, or not, you would like to print debugging messages: :param debug_prefix: The prefix to print with the debugging messages. :param debug_only_errors: Whether you would like to print only error messages or all messages. :return: None. """ - # Prepare the combined base filter: - sms_filter = {"serviceType": "sms"} - if base_filter: - for k, v in base_filter.items(): sms_filter[k] = v - # Invoke the parent's constructor: super().__init__( cache = cache, alert_url = alert_url, http_client = http_client, - base_filter = sms_filter, + base_filter = {"client": "nimbusSmsIndia"}, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors @@ -173,16 +146,122 @@ class SMSController(CoreMessageController, ABC): # ┗┛┛ ┗┗┛ ┗┛┗ ┛┗┗┻┗┛┗┗┫ # ┛ - @abstractmethod - async def send( + async def send_one_sms( self, - mongo_conn: AsyncMongo, + mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, - messages: List[NimbusSMSIndiaMessage | SavvyBulkSMSKenyaMessage], + client: AsyncNimbusSMS, + message: NimbusSMSIndiaMessage, + tags: List[Any] + ) -> SMSSendOneResult: + + """ + Use this to send one SMS. There are just 2 steps here - send the SMS, and store its details in the database. + :param mongo_data_conn: The database connection to use to perform this task. + :param auth_token: The auth token that will be used to send this message. + :param client: The third-party SMS client to use to send this message. + :param message: The actual message that needs to be sent. + :param tags: Any tags to attach with this SMS for filtering when querying in the listing service. + :return: The structured result of sending one message. + """ + + # Send the SMS: + client_response = await client.send_sms( + recipient_number = message.recipientNo, + message = message.text, + template_id = message.templateId + ) + + # Convert the format of the SMS client's response to the core message model. + sent_message_model = CoreMessageModel( + ts = client_response.ts, + syncTs = date_time.get_current_utc_date_time(as_string = False), + tokenId = auth_token.authTokenId, + serviceType = auth_token.serviceType, + client = auth_token.client, + clientMessageId = client_response.messageId, + clientThreadId = message.recipientNo, + isSent = True, + isBroadcast = False, + sentSuccessfully = client_response.success, + sender = auth_token.auth["senderId"], + recipient = message.recipientNo, + chat = message.recipientNo, + message = client_response.model_dump(), + snippet = message.text, + aiSnippet = None, + tags = list(set(tags + ["SMS", "Nimbus SMS", "India"])) + ) + + # Save the result to the database: + message_id = await self.save_one_message( + mongo_data_conn = mongo_data_conn, + message = sent_message_model + ) + self._printer(message_id, client_response.success) + + # Done here: + success = True if client_response.success and message_id else False + return SMSSendOneResult( + success = success, + message = "SMS sent successfully." if client_response.success else "SMS sending failed.", + smsMessage = message + ) + + async def send_many_sms( + self, + mongo_data_conn: AsyncMongo, + auth_token: CoreAuthTokenModel, + messages: List[NimbusSMSIndiaMessage], tags: List[Any] ) -> SMSSendManyResults: - pass + """ + Use this to send multiple SMS messages. This method just calls the individual SMS sending method for every + individual message, and then aggregates the results. + :param mongo_data_conn: The database connection to use to perform this task. + :param auth_token: The auth token that will be used to send this message. + :param messages: The list of messages to send out. + :param tags: Any tags to attach with these SMS for filtering when querying in the listing service. The same tags + will be applied to all messages. Do not call this method if you need to have different tags for all of them. + :return: The structured result of sending many SMS messages. + """ + + # Start with a blank variable: + cumulative_results = SMSSendManyResults() + + # Make the client from the auth-token: + client = AsyncNimbusSMS( + entity_id = auth_token.auth["entityId"], + sender_id = auth_token.auth["senderId"], + user_id = auth_token.auth["userId"], + api_key = auth_token.auth["apiKey"], + http_client = self._http_client + ) + + # Create and fire all the SMS-sending tasks: + tasks = [ + self.send_one_sms( + mongo_data_conn = mongo_data_conn, + auth_token = auth_token, + client = client, + message = message, + tags = tags + ) + for message in messages + ] + individual_results = await asyncio.gather(*tasks) + + # Prepare the final result: + for result in individual_results: + if result.success: cumulative_results.successCount += 1 + else: cumulative_results.failureCount += 1 + cumulative_results.totalCount += 1 + cumulative_results.smsMessages.append(result.smsMessage) + cumulative_results.message = f"{cumulative_results.successCount}/{cumulative_results.totalCount} SMS sent." + + # Done here: + return cumulative_results # ***************************************************************************************************************** diff --git a/controllers_v2/sms/savvy_bulk_sms_kenya.py b/controllers_v2/sms/savvy_bulk_sms_kenya.py index ccf139b..48aebfa 100644 --- a/controllers_v2/sms/savvy_bulk_sms_kenya.py +++ b/controllers_v2/sms/savvy_bulk_sms_kenya.py @@ -10,7 +10,8 @@ OBJECTIVE: - To handle all SMS related behaviour for Nimbus It's service from one place. + To handle all SMS related behaviour for Savvy Bulk SMS's service from one place. + This service is for Kenya only. REFERENCES: @@ -47,13 +48,13 @@ from controllers_v2.sms.base import SMSController from models.core.auth_token import CoreAuthTokenModel from models.core.message import CoreMessageModel from models.api.sms.send import ( - NimbusSMSIndiaMessage, + SavvyBulkSMSKenyaMessage, SMSSendOneResult, SMSSendManyResults ) # SMS Clients: -from utils_v2.sms.india.nimbus.controllers.async_nimbus import AsyncNimbusSMS +from utils_v2.sms.kenya.savvy_bulk_sms.controllers.async_savvy_bulk_sms import AsyncSavvyBulkSMS # To work with datatypes: from typing import List, Any @@ -102,7 +103,7 @@ import asyncio # ***************************************************************************************************************** -class NimbusSMSIndiaController(SMSController): +class SavvyBulkSMSKenyaController(SMSController): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -114,7 +115,7 @@ class NimbusSMSIndiaController(SMSController): http_client: httpx.AsyncClient = None, alert_url: str = None, debug: bool = True, - debug_prefix: str = "SMS (C) | ", + debug_prefix: str = "Savvy SMS (C) | ", debug_only_errors: bool = True ): @@ -134,7 +135,7 @@ class NimbusSMSIndiaController(SMSController): cache = cache, alert_url = alert_url, http_client = http_client, - base_filter = {"client": "nimbusSmsIndia"}, + base_filter = {"client": "savvyBulkSmsKenya"}, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors @@ -149,8 +150,8 @@ class NimbusSMSIndiaController(SMSController): self, mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, - client: AsyncNimbusSMS, - message: NimbusSMSIndiaMessage, + client: AsyncSavvyBulkSMS, + message: SavvyBulkSMSKenyaMessage, tags: List[Any] ) -> SMSSendOneResult: @@ -167,8 +168,7 @@ class NimbusSMSIndiaController(SMSController): # Send the SMS: client_response = await client.send_sms( recipient_number = message.recipientNo, - message = message.text, - template_id = message.templateId + message = message.text ) # Convert the format of the SMS client's response to the core message model. @@ -183,13 +183,13 @@ class NimbusSMSIndiaController(SMSController): isSent = True, isBroadcast = False, sentSuccessfully = client_response.success, - sender = None, + sender = auth_token.auth["shortCode"], recipient = message.recipientNo, - chat = None, + chat = message.recipientNo, message = client_response.model_dump(), snippet = message.text, aiSnippet = None, - tags = list(set(tags + ["SMS", "Nimbus SMS", "India"])) + tags = list(set(tags + ["SMS", "Savvy Bulk SMS", "Kenya"])) ) # Save the result to the database: @@ -197,20 +197,21 @@ class NimbusSMSIndiaController(SMSController): mongo_data_conn = mongo_data_conn, message = sent_message_model ) + self._printer(message_id, client_response.success) # Done here: success = True if client_response.success and message_id else False return SMSSendOneResult( success = success, message = "SMS sent successfully." if client_response.success else "SMS sending failed.", - smsMessage = sent_message_model + smsMessage = message ) async def send_many_sms( self, mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, - messages: List[NimbusSMSIndiaMessage], + messages: List[SavvyBulkSMSKenyaMessage], tags: List[Any] ) -> SMSSendManyResults: @@ -229,10 +230,9 @@ class NimbusSMSIndiaController(SMSController): cumulative_results = SMSSendManyResults() # Make the client from the auth-token: - client = AsyncNimbusSMS( - entity_id = auth_token.auth["entityId"], - sender_id = auth_token.auth["senderId"], - user_id = auth_token.auth["userId"], + client = AsyncSavvyBulkSMS( + partner_id = auth_token.auth["partnerId"], + short_code = auth_token.auth["shortCode"], api_key = auth_token.auth["apiKey"], http_client = self._http_client ) @@ -256,6 +256,7 @@ class NimbusSMSIndiaController(SMSController): else: cumulative_results.failureCount += 1 cumulative_results.totalCount += 1 cumulative_results.smsMessages.append(result.smsMessage) + cumulative_results.message = f"{cumulative_results.successCount}/{cumulative_results.totalCount} SMS sent." # Done here: return cumulative_results diff --git a/models/api/mail/send.py b/models/api/mail/send.py index cd3ffaf..f1e60d6 100644 --- a/models/api/mail/send.py +++ b/models/api/mail/send.py @@ -36,7 +36,7 @@ sys.path.append(".") sys.path.append("..") # For making data behaviour_models: -from pydantic import BaseModel, Field, field_validator, PastDatetime +from pydantic import BaseModel, Field, field_validator, PastDatetime, EmailStr from typing import Optional, Literal, List # My utils: @@ -103,9 +103,33 @@ class MailSendRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- +class MailSendInlineFiles(BaseModel): + + key: str = Field( + description = "the key in the form data under which the file has been sent", + frozen = True + ) + + cid: str = Field( + description = "the content id to assign to the file", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# --------------------------------------------------------------------------------------------------------------------- + + class MailSendRequestData(BaseModel): - tokenKey: str = Field( + tokenKey: ObjectId = Field( description = "the account identifier (Mongo ObjectId)", frozen = True ) @@ -115,11 +139,34 @@ class MailSendRequestData(BaseModel): frozen = True ) - inlineKeys: List[str] = Field( + to: List[EmailStr] = Field( + description = "the recipient of your mail", + # default = None, + # validate_default = True, + frozen = True + ) + + cc: List[EmailStr] = Field( + description = "the list of ids to add as cc", + default = None, + validate_default = True, + frozen = True + ) + + bcc: List[EmailStr] = Field( + description = "the list of ids to add as bcc", + default = None, + validate_default = True, + frozen = True + ) + + inlineFiles: List[MailSendInlineFiles] = Field( description = ( "when sending files, this will be a list of keys whose " "associated files will be treated as inline files" ), + default = None, + validate_default = True, frozen = True ) @@ -142,13 +189,35 @@ class MailSendRequestData(BaseModel): except: pass return value - @field_validator("inlineKeys", mode = "before") + @field_validator("inlineFiles", mode = "before") def parse_json(cls, value): - try: - value = json.from_string(value) - if not isinstance(value, list): raise ValueError("'inlineKeys' needs to be an array") - except Exception as exception: - raise ValueError("'inlineKeys' could not be parsed") + + # If we get a null value or an empty string: + if value is None: return [] + if isinstance(value, str): + if not value.strip(): return [] + + # If we get a properly populated string: + try: value = json.from_string(value) + except: pass + + # Done here: + return value + + @field_validator("to", "cc", "bcc", mode = "before") + def parse_recipients(cls, value): + + # If we get a null value or an empty string: + if value is None: return [] + if isinstance(value, str): + if not value.strip(): return [] + + # If we get a properly populated string: + try: value = json.from_string(value) + except: value = [value.strip()] + + # Done here: + return value # ***************************************************************************************************************** diff --git a/models/api/mail/sync.py b/models/api/mail/sync.py index 3e368e7..65612e4 100644 --- a/models/api/mail/sync.py +++ b/models/api/mail/sync.py @@ -219,6 +219,35 @@ class MailSyncManyResults(BaseModel): extra = "forbid" +# --------------------------------------------------------------------------------------------------------------------- + + +class MailSendOneResult(BaseModel): + + success: bool = Field( + description = "whether, or not, the mail was successfully sent", + default = False + ) + + message: str | None = Field( + description = "a brief message to summarize the result of the process", + default = None + ) + + mailMessage: CoreMessageModel | None = Field( + description = "the actual data of the mail; can be null in a successful process if the mail is already sent", + default = None + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ***************************************************************************************************************** # ***** **** # *** MAIN PROGRAM *** diff --git a/models/api/sms/list.py b/models/api/sms/list.py index 1191c21..04bd4a5 100644 --- a/models/api/sms/list.py +++ b/models/api/sms/list.py @@ -75,7 +75,7 @@ REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9] # ***************************************************************************************************************** -class MailListRequestHeaders(BaseModel): +class SMSListRequestHeaders(BaseModel): sessionToken: str = Field( description = "the session token of the user who is requesting the service", @@ -99,7 +99,7 @@ class MailListRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class MailListRequestData(BaseModel): +class SMSListRequestData(BaseModel): tokenKeys: str | List[str] = Field( description = "the token identifier(s) that tell you which auth-tokens were used for fetching those messages", diff --git a/models/api/sms/send.py b/models/api/sms/send.py index 6e64cf6..05b0e73 100644 --- a/models/api/sms/send.py +++ b/models/api/sms/send.py @@ -43,7 +43,7 @@ from typing import Optional, Literal, Union, List, Any from utils_v2.string import regex from utils_v2.date_time import date_time -# Data models: +# Models: from models.core.message import CoreMessageModel from utils_v2.sms.models.sms_message import SentSMSMessageModel diff --git a/models/api/sms/tags.py b/models/api/sms/tags.py index dc2a39f..655a276 100644 --- a/models/api/sms/tags.py +++ b/models/api/sms/tags.py @@ -6,11 +6,11 @@ DATE: - Friday, 13th Dec., 2024. + Thursday, 19th Dec., 2024. OBJECTIVE: - To provide a structure to work with the tags on mail messages. + To provide a structure to work with the tags on SMS messages. REFERENCES: @@ -75,7 +75,7 @@ REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9] # ***************************************************************************************************************** -class MailUpdateTagsRequestHeaders(BaseModel): +class SMSUpdateTagsRequestHeaders(BaseModel): sessionToken: str = Field( description = "the session token of the user who is requesting the service", @@ -99,7 +99,7 @@ class MailUpdateTagsRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class MailUpdateTagsRequestData(BaseModel): +class SMSUpdateTagsRequestData(BaseModel): messageId: str = Field( description = "the mail identifier (Mongo ObjectId) of the document that holds the mail", diff --git a/models/core/message.py b/models/core/message.py index 0a88e3d..2a01c62 100644 --- a/models/core/message.py +++ b/models/core/message.py @@ -82,11 +82,12 @@ import datetime class CoreMessageModel(BaseModel): - messageId: ObjectId = Field( + messageId: ObjectId | None = Field( description = "the id of the document in mongodb that holds this information", frozen = True, default = None, - alias = "_id" + alias = "_id", + exclude = True ) ts: AwareDatetime = Field( @@ -291,7 +292,7 @@ if __name__ == "__main__": }, sender = "Polki", chat = "T6 Cats", - preview = "Hi, there! How do you do?" + snippet = "Hi, there! How do you do?" ) print("MESSAGE MODEL:", json.to_string(message.model_dump(), default = str))