diff --git a/api/blueprints/mail/oauth_callback.py b/api/blueprints/mail/oauth_callback.py index 4fb93b8..08349b9 100644 --- a/api/blueprints/mail/oauth_callback.py +++ b/api/blueprints/mail/oauth_callback.py @@ -163,8 +163,11 @@ async def mail_callback( tokens.email = user_profile.data["emailAddress"] if user_profile.success else None # Save the tokens to the database - tokens_saved = await MailOAuthModel.set_token( - db_conn = current_app.data_mongo, + tokens_saved = await current_app.mail_oauth_model.set_token( + db_conn = current_app.sql_writer, + mongo_conn = current_app.data_mongo, + session_token = inbound_headers["X-Session-Token"], + user_info = kwargs["session_info"], user_identifier = inbound_data["state"], token = tokens.model_dump() ) @@ -174,7 +177,7 @@ async def mail_callback( # ┛┗┗ ┛┣┛┗┛┛┗┛┗ # ┛ - # # Return a response: + # # Return a JSON response: # return ResponseModel( # status_code = StatusCodes.OK if tokens_saved else StatusCodes.FAILED, # http_code = HttpCodes.SUCCESS if tokens_saved else HttpCodes.INTERNAL_SERVER_ERROR, @@ -184,7 +187,7 @@ async def mail_callback( # } # ) - # Return a page that shows you the status of your authorization: + # Return an HTML response: return await render_template( "/mail/oauth/oauth_success.html" if tokens_saved else "/mail/oauth/oauth_failure.html", mail_client = mail_client.title() diff --git a/api/blueprints/mail/oauth_request.py b/api/blueprints/mail/oauth_request.py index 14b0769..acd54cf 100644 --- a/api/blueprints/mail/oauth_request.py +++ b/api/blueprints/mail/oauth_request.py @@ -66,9 +66,6 @@ from utils_v2.goog.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT # Common: from shared import constants -# Behaviour Models: -from models.behaviour.mail.oauth import MailOAuthModel - # Data Models: from models.data.mail.oauth import ( OAuthMailAuthorizationRequestHeaders, @@ -178,8 +175,10 @@ async def request_oauth_authorization_url( # ┛ # Make a user identifier from the session info: - user_identifier = await MailOAuthModel.get_id( - db_conn = current_app.data_mongo, + user_identifier = await current_app.mail_oauth_model.get_user_identifier( + db_conn = current_app.sql_writer, + mongo_conn = current_app.data_mongo, + session_token = inbound_headers["X-Session-Token"], user_info = kwargs["session_info"], service_type = "email", service_client = inbound_data.mailClient, diff --git a/api/blueprints/tech/__init__.py b/api/blueprints/tech/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/tech/chat_alerts.py b/api/blueprints/tech/chat_alerts.py new file mode 100644 index 0000000..4200a27 --- /dev/null +++ b/api/blueprints/tech/chat_alerts.py @@ -0,0 +1,202 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 29th Nov., 2024 + + OBJECTIVE: + + To manage alert sending from one place. You may have various outlets for alerts, like Telegram, WhatsApp, etc. + Add endpoints for them here. Note that these alerts will be for the internal tech admins. It won't be for the + client-facing comms. Create a separate blueprint for that. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app + +# My utils: +from utils_v2.string import json +from utils_v2.date_time import date_time +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Data models: +from models.data.tech.alerts import ( + ChatAlertRequestHeaders, + ChatAlertRequestData +) + +# Common: +from shared import constants + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +tech_chat_alert_bp = Blueprint("tech_chat_alert", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@tech_chat_alert_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@tech_chat_alert_bp.route("/chat/", methods = ["POST"]) +@set_api_version(api_version = "3.0.0") +@read_input(sanitize_headers = True, sanitize_data = True) +@get_session_info(key = "X-Session-Token", session_coro = "get_session") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@validate_input( + header_validator = lambda x: ChatAlertRequestHeaders(**x).model_dump(), + data_validator = lambda x: ChatAlertRequestData(**x) +) +@handle_cancelled_request() +async def user_login( + source: str = None, + inbound_headers: dict = None, + inbound_data: dict | ChatAlertRequestData = None, + inbound_files: dict = None, + **kwargs +): + + """ + To relay an alert over some form of chat client like Telegram and WhatsApp. + :param source: The identifier of the source of the alert. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + response = None + + # ┳┳┓ ┏┓ • + # ┃┃┃┏┓┏┏┏┓┏┓┏┓ ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┓┏┓┏┓ + # ┛ ┗┗ ┛┛┗┻┗┫┗ ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┗┛┛┗ + # ┛ + + # Capture the event's time, and the person who caused the event: + event_dt = date_time.get_current_ist_date_time().strftime("%Y-%m-%d %I:%M:%S %p %Z") + if kwargs.get("session_info"): username = kwargs["session_info"].get("fullName") + else: username = None + + # Construct the prefix: + if inbound_data.type == "warning": message_type = "⚠️ WARNING FROM" + elif inbound_data.type == "error": message_type = "🚨 ERROR IN" + else: message_type = "ℹ️ INFO FROM" + message_prefix = f"{message_type} *TCAOFF (Converse)*!\n{event_dt}\n\n" + message_prefix += f"*From:*\n`{username} (via '{source}')`\n\n" + + # ┏┳┓ ┓ + # ┃ ┏┓┃┏┓┏┓┏┓┏┓┏┳┓ + # ┻ ┗ ┗┗ ┗┫┛ ┗┻┛┗┗ + # ┛ + + if inbound_data.chatClient == "telegram": + response = await current_app.http_client.post( + url = current_app.script_data["telegram"]["connectors"]["nexcom"]["url"], + json = { + "appKey": current_app.script_data["telegram"]["connectors"]["nexcom"]["appKey"], + "chatId": inbound_data.chatId or current_app.script_data["telegram"]["chatIds"]["tcaoff"], + "message": message_prefix + inbound_data.message + } + ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Construct and return the response: + response_json = response.json() + success = True if response_json.get("status", 0) == 1 else False + return ResponseModel( + status_code = StatusCodes.OK if success else StatusCodes.FAILED, + message = response_json.get("message", "unknown failure") + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/helpers/user/session.py b/api/helpers/user/session.py index 3362a8a..dde608d 100644 --- a/api/helpers/user/session.py +++ b/api/helpers/user/session.py @@ -87,6 +87,7 @@ async def get_session(session_token): raw_info = await current_app.module_cache.get(key = session_token) session_info = { "fullName": raw_info["value"]["full_name"], + "userId": raw_info["value"]["user_id"], "entityId": raw_info["value"]["entity_id"], "billingAccountId": raw_info["value"]["billing_account_id"], "departmentId": raw_info["value"]["department_id"], diff --git a/api/main.py b/api/main.py index db3050f..c926df1 100644 --- a/api/main.py +++ b/api/main.py @@ -53,6 +53,7 @@ from utils_v2.string import json from utils_v2.api import async_quart from utils_v2.date_time import date_time from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache from utils_v2.serialization.json_serializer import JSONSerializer from utils_v2.api.async_quart import ( @@ -67,7 +68,10 @@ from utils_v2.api.async_quart import ( ) # GMail-related utils: -from utils_v2.goog.gmail.gmail_client import AsyncGMailClient, SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.gmail.gmail_client import AsyncGMailClient + +# Behaviour Models: +from models.behaviour.mail.oauth import MailOAuthModel # To make REST API calls: import httpx @@ -78,6 +82,7 @@ from icecream import IceCreamDebugger # All the blueprints: from api.blueprints.mail.oauth_request import mail_oauth_bp from api.blueprints.mail.oauth_callback import mail_callback_bp +from api.blueprints.tech.chat_alerts import tech_chat_alert_bp from api.blueprints.test.callback import test_callback_bp # All the helpers: @@ -108,6 +113,7 @@ app = Quart(__name__, template_folder = r"../views") app = cors(app) app.register_blueprint(mail_oauth_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_callback_bp, url_prefix = f"/{MODULE_BASE}/mail") +app.register_blueprint(tech_chat_alert_bp, url_prefix = f"/{MODULE_BASE}/tech/alert") app.register_blueprint(test_callback_bp, url_prefix = f"/{MODULE_BASE}/test") @@ -184,6 +190,10 @@ async def app_startup(**kwargs): ) ) + # ┏┓ ┓ ┓ ┳┓ + # ┃ ┏┓┏┓┏┫ ┏┓┏┓┏┫ ┃┃┏┓╋┏┓ + # ┗┛┛ ┗ ┗┻ ┗┻┛┗┗┻ ┻┛┗┻┗┗┻ + # Get the script credentials and data: script_id = os.environ["SCRIPT_ID"] response = await current_app.http_client.get( @@ -197,10 +207,19 @@ async def app_startup(**kwargs): ) current_app.script_data = response.json().get("data") + # ┏┓┏┓┳┳ ┏┓┏┏• • + # ┃ ┃┃┃┃ ┣┫╋╋┓┏┓┓╋┓┏ + # ┗┛┣┛┗┛ ┛┗┛┛┗┛┗┗┗┗┫ + # ┛ + # We set the CPU affinity: try: set_cpu_affinity(script_cred["cpuAffinity"]) except Exception as exception: current_app.printer(exception) + # ┳┓ ┓• ┏┓ ┓ + # ┣┫┏┓┏┫┓┏ ━━ ┃ ┏┓┏┣┓┏┓ + # ┛┗┗ ┗┻┗┛ ┗┛┗┻┗┛┗┗ + # Caching connections: current_app.rate_limit_cache = AsyncRedisCache( connection_string = script_cred["redisCache"]["rateLimit"]["connectionString"], @@ -214,6 +233,33 @@ async def app_startup(**kwargs): debug_prefix = "User Cache | " ) + # ┳┳┓ • ┳┓┳┓ + # ┃┃┃┏┓┏┓┓┏┓┃┃┣┫ + # ┛ ┗┗┻┛ ┗┗┻┻┛┻┛ + + # MariaDB connections: + current_app.sql_writer = AsyncMySQL( + pool_size = script_cred["mariaDb"]["write"]["poolSize"], + host = script_cred["mariaDb"]["write"]["host"], + user = script_cred["mariaDb"]["write"]["user"], + password = script_cred["mariaDb"]["write"]["password"], + database = script_cred["mariaDb"]["write"]["database"] + ) + await current_app.sql_writer.connect() + current_app.sql_reader = AsyncMySQL( + pool_size = script_cred["mariaDb"]["read"]["poolSize"], + host = script_cred["mariaDb"]["read"]["host"], + user = script_cred["mariaDb"]["read"]["user"], + password = script_cred["mariaDb"]["read"]["password"], + database = script_cred["mariaDb"]["read"]["database"] + ) + await current_app.sql_reader.connect() + + # ┳┳┓ + # ┃┃┃┏┓┏┓┏┓┏┓ + # ┛ ┗┗┛┛┗┗┫┗┛ + # ┛ + # MongoDB connections: current_app.logs_mongo = AsyncMongo( connection_string = script_cred["mongoDb"]["logs"]["connectionString"], @@ -230,6 +276,23 @@ async def app_startup(**kwargs): ) await current_app.data_mongo.connect() + # ┳ ┓ ┳┳┓ ┓ ┓ + # ┃┏┓╋┏┓┏┓┏┓┏┓┃ ┃┃┃┏┓┏┫┏┓┃┏ + # ┻┛┗┗┗ ┛ ┛┗┗┻┗ ┛ ┗┗┛┗┻┗ ┗┛ + + current_app.mail_oauth_model = MailOAuthModel( + cache = current_app.module_cache, + alert_url = current_app.script_data["alerts"]["url"], + http_client = current_app.http_client, + debug = enable_debugging, + debug_prefix = "Mail-OAuth | ", + debug_only_errors = True + ) + + # ┏┓ + # ┃ ┏┓┏┓┏┓┏┓┏╋┏┓┏┓┏ + # ┗┛┗┛┛┗┛┗┗ ┗┗┗┛┛ ┛ + # Create an instance to handle GMail-related activities: current_app.gmail_client = AsyncGMailClient( service_name = "gmail", diff --git a/models/behaviour/base.py b/models/behaviour/base.py new file mode 100644 index 0000000..6659613 --- /dev/null +++ b/models/behaviour/base.py @@ -0,0 +1,388 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 22nd Oct., 2024 + + OBJECTIVE: + + To provide an easy way to create models to handle documents for Bicree. + This is the base model for this microservice. It will define the structure for all other models that will be + used in this particular microservice. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# My utils: +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 + +# To work with datatypes: +from typing import List + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class BaseModel: + + PREVIEW_LENGTH = 250 + + def __init__( + self, + cache = None, + alert_url = None, + http_client = None, + debug = True, + debug_prefix = "Model | ", + debug_only_errors = True + ): + + """ + This is the base model. + :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. + """ + + # Prepare the caching utility: + self._cache = cache + + # For sending alerts: + self._alert_url = alert_url + self._http_client = http_client + + # Prepare the debugging utility: + self._debug_prefix = debug_prefix + self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True) + 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.") + + def enable_terminal_print(self): + self._printer.enable() + + def disable_terminal_print(self): + self._printer.disable() + + def debug_only_errors(self): + self._debug_only_errors = True + + def debug_everything(self): + self._debug_only_errors = False + + async def send_alert( + self, + message: str, + session_token = None, + alert_type = "error" + ): + + """ + Sends out an alert (ideally through the tech module). This is meant to be used when some exception occurs, and + you want to be informed before the client complains. + :param message: The message to send out to the admins. + :param session_token: The session token of the user (optional) so that the alert message can display the name of + the user who faced the trouble. + :param alert_type: The type of alert to throw ("error", "warning", or "info"). + :return: None. + """ + + if self._http_client is not None and self._alert_url is not None: + response = await self._http_client.post( + url = self._alert_url, + json = { + "sessionToken": session_token, + "message": message, + "type": alert_type + } + ) + + async def call_cached_procedure( + self, + cache: AsyncRedisCache, + cache_key: str, + cache_expiry: int, + db_conn: AsyncMySQL, + proc_name: str, + proc_args: tuple, + retry_count: int = 1, + backoff_seconds: float = 0.5, + backoff_multiplier: float = 1.1, + session_token: str = None + ): + + """ + Calls a stored procedure and returns the response as a JSON-like object (dict or list). + :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 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. + :param backoff_seconds: The time to wait before making the next attempt if the retry count is more than 1. + :param backoff_multiplier: The factor that dictates how much to modify the time delay by when waiting to retry. + :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 + issue. + :return: The response from the stored procedure. + """ + + # check for the data in cache: + data = await cache.get(cache_key) + + # If the data isn't in the cache, call the procedure: + if data is None: + + # Make the database call: + data = await self.call_procedure( + db_conn = db_conn, + proc_name = proc_name, + proc_args = proc_args, + retry_count = retry_count, + backoff_seconds = backoff_seconds, + backoff_multiplier = backoff_multiplier, + session_token = session_token + ) + + # If the database call succeeded, cache the response: + if isinstance(data, dict) and data["status"] == 1: + await cache.set(key = cache_key, value = data, expiry = cache_expiry) + + # Done here: + return data + + async def call_procedure( + self, + db_conn: AsyncMySQL, + proc_name: str, + proc_args: tuple, + retry_count: int = 1, + backoff_seconds: float = 0.5, + backoff_multiplier: float = 1.1, + session_token: str = None + ): + + """ + 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 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. + :param backoff_seconds: The time to wait before making the next attempt if the retry count is more than 1. + :param backoff_multiplier: The factor that dictates how much to modify the time delay by when waiting to retry. + :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 + issue. + :return: The response from the stored procedure. + """ + + # Call the stored procedure: + db_json, exception = await db_conn.call_procedure_and_get_json( + proc_name, + proc_args, + retry_count = retry_count, + backoff_seconds = backoff_seconds, + backoff_multiplier = backoff_multiplier, + return_exception = True + ) + + # Understand the response: + success = True if db_json["status"] == 1 else False + message = db_json.get("message") + + # Debugging print: + if not success or not self._debug_only_errors: + self._printer(proc_name, proc_args, success, exception, message) + + # Send an alert out on exceptions: + if exception is not None: + + # Format the message in Markdown format: + exception_string = str(exception).replace("`", "'") + formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n" + formatted_message += f"*Proc:*\n`{proc_name}`\n\n" + formatted_message += f"*Args:*\n`({', '.join([str(_) for _ in proc_args])})`\n\n" + formatted_message += f"*Arg-Types:*\n`({', '.join([type(_).__name__ for _ in proc_args])})`\n\n" + formatted_message += f"*Message:*\n`{message}`\n\n" + formatted_message += f"*Success:*\n`{success}`\n\n" + formatted_message += f"*Exception:*\n`{exception_string}`\n\n" + + # Send the alert: + await self.send_alert(formatted_message, session_token = session_token) + + # Return the response: + db_json["status_code"] = StatusCodes.OK if success else StatusCodes.FAILED + return db_json + + async def execute_one( + self, + db_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 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 + issue. + :return: The response from the database. + """ + + # Run the query: + rows_affected, db_response, exception = await db_conn.execute_one(query = query, return_exception = True) + + # Send an alert out on exceptions: + if exception is not None: + + # Created needed previews: + query_preview = query if len(query) <= self.PREVIEW_LENGTH else query[:self.PREVIEW_LENGTH] + "..." + + # Format the message in Markdown format: + formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n" + formatted_message += f"*Query:*\n`{query_preview}`\n\n" + formatted_message += f"*Rows Affected:*\n`{rows_affected}`\n\n" + formatted_message += f"*DB Response:*\n`{db_response}`\n\n" + formatted_message += f"*Exception:*\n`{exception}`\n\n" + + # Send the alert: + await self.send_alert(formatted_message, session_token = session_token) + + # Return the response: + return rows_affected, db_response + + async def execute_many( + self, + db_conn: AsyncMySQL, + query: str, + data: List[tuple], + session_token: str = None + ): + + """ + Runs many queries and sends an alert if that fails. + :param db_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 + occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the + issue. + :return: The response from the database. + """ + + # Run the query: + rows_affected, db_response, exception = await db_conn.execute_many( + query = query, + data = data, + return_exception = True + ) + + # Send an alert out on exceptions: + if exception is not None: + + # Created needed previews: + query_preview = query if len(query) <= self.PREVIEW_LENGTH else query[:self.PREVIEW_LENGTH] + "..." + data_preview = str(data) + if len(data_preview) > self.PREVIEW_LENGTH: data_preview = data_preview[:self.PREVIEW_LENGTH] + "..." + + # Format the message in Markdown format: + formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n" + formatted_message += f"*Query:*\n`{query_preview}`\n\n" + formatted_message += f"*Data:*\n`{data_preview}`\n\n" + formatted_message += f"*Rows Affected:*\n`{rows_affected}`\n\n" + formatted_message += f"*DB Response:*\n`{db_response}`\n\n" + formatted_message += f"*Exception:*\n`{exception}`\n\n" + + # Send the alert: + await self.send_alert(formatted_message, session_token = session_token) + + # Return the response: + return rows_affected, db_response + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/behaviour/mail/oauth.py b/models/behaviour/mail/oauth.py index 98ad909..b30fcff 100644 --- a/models/behaviour/mail/oauth.py +++ b/models/behaviour/mail/oauth.py @@ -38,8 +38,12 @@ sys.path.append("..") # 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 +# Base model: +from models.behaviour.base import BaseModel + # To work with MongoDB: from bson import ObjectId @@ -87,31 +91,32 @@ import copy # ***************************************************************************************************************** -class MailOAuthModel: +class MailOAuthModel(BaseModel): AUTH_COLLECTION = "_authTokens" - def __init__(self): - pass - - @staticmethod - async def get_id( - db_conn: AsyncMongo, + async def get_user_identifier( + self, + db_conn: AsyncMySQL, + mongo_conn: AsyncMongo, user_info: dict, service_type: Literal["email", "chat"], service_client: Literal["gmail"], - auth_type: Literal["oauth"] + auth_type: Literal["oauth"], + session_token: str = None ) -> ObjectId: """ 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 to use to perform the action. + :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 user_info: The dictionary that has the user's session information. :param service_type: The type of service being provided. :param service_client: The name of the company or brand that is providing this service that is being integrated. :param auth_type: To identify the type of authentication being done here. This could indicate simple password authentication, more advance OAuth2.0 authentication, etc. + :param session_token: The session token of the user who requested this service. :return: An ObjectId to later store the granted tokens. """ @@ -119,7 +124,7 @@ class MailOAuthModel: request_ts = date_time.get_current_utc_date_time(as_string = False) # Get the identifier from the database: - db_json = await db_conn.find_one_and_update( + mongo_json = await mongo_conn.find_one_and_update( collection = MailOAuthModel.AUTH_COLLECTION, filter = { "serviceType": service_type, @@ -150,30 +155,59 @@ class MailOAuthModel: return_updated = True ) - # Done here: - return db_json["_id"] if db_json else None + # Tell MariaDB that an authorization request was initiated: + db_json = {} + if mongo_json is not None: + db_json = await self.call_procedure( + db_conn = db_conn, + proc_name = "entity_integration_save", + proc_args = ( + user_info["entityId"], # .... 'p_entity_id' + service_client, # ........... 'p_provider' + "Auth Requested", # ......... 'p_current_status' + "Auth URL Generated", # ..... 'p_last_action' + None, # ..................... 'p_display_name' + None, # ..................... 'p_display_picture' + str(mongo_json["_id"]), # ... 'p_token_id' + None, # ..................... 'p_notes' + user_info["userId"] # ....... 'p_created_by' + ), + session_token = session_token + ) + + # Done here: + return mongo_json["_id"] if mongo_json and db_json.get("status") == 1 else None - @staticmethod async def set_token( - db_conn: AsyncMongo, + self, + db_conn: AsyncMySQL, + mongo_conn: AsyncMongo, + user_info: dict, user_identifier: ObjectId | str, - token: dict + token: dict, + session_token: str = None ) -> bool: """ 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. - :param db_conn: The database connection to use to perform the action. - :param user_identifier: The identifier granted by the 'get_id' method. + :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 user_info: The dictionary that has the user's session information. + :param user_identifier: The identifier granted by the 'get_user_identifier' method. :param token: The token granted by the third-party service. - :return: + :param session_token: The session token of the user who requested this service. + :return: True if saved, False if failed. """ + # Start by assuming failure: + token_saved = False + # Note down the timestamp at which this event occurred: request_ts = date_time.get_current_utc_date_time(as_string = False) - # Save the token to the database: - token_saved = await db_conn.update_one( + # Save the token to MongoDB: + mongo_json = await mongo_conn.find_one_and_update( collection = MailOAuthModel.AUTH_COLLECTION, filter = {"_id": ObjectId(user_identifier)}, update = { @@ -181,9 +215,32 @@ class MailOAuthModel: "token": token, "firstRefreshTs": request_ts, } - } + }, + projection = {"token": False}, + return_updated = True, + upsert = False ) + # Tell MariaDB that the token was saved: + if mongo_json is not None: + db_json = await self.call_procedure( + db_conn = db_conn, + proc_name = "entity_integration_save", + proc_args = ( + user_info["entityId"], # ... 'p_entity_id' + mongo_json["client"], # .... 'p_provider' + "Auth Granted", # .......... 'p_current_status' + "Set Token", # ............. 'p_last_action' + None, # .................... 'p_display_name' + None, # .................... 'p_display_picture' + user_identifier, # ......... 'p_token_id' + token["email"], # .......... 'p_notes' + user_info["userId"] # ...... 'p_created_by' + ), + session_token = session_token + ) + if db_json["status"] == 1: token_saved = True + # Done here: return token_saved diff --git a/models/behaviour/mail/sync.py b/models/behaviour/mail/sync.py index febdce5..1d835cf 100644 --- a/models/behaviour/mail/sync.py +++ b/models/behaviour/mail/sync.py @@ -10,7 +10,7 @@ OBJECTIVE: - Here we perform on-time mail syncing activities for our users. + Here we perform one-time mail syncing activities for our users. REFERENCES: @@ -86,7 +86,7 @@ import copy # ***************************************************************************************************************** -class MailOAuthModel: +class MailSyncModel: AUTH_COLLECTION = "_authTokens" diff --git a/models/data/tech/__init__.py b/models/data/tech/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/tech/alerts.py b/models/data/tech/alerts.py new file mode 100644 index 0000000..c9bab94 --- /dev/null +++ b/models/data/tech/alerts.py @@ -0,0 +1,156 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Saturday, 26th Oct., 2024. + + OBJECTIVE: + + To provide a data models for user-related activities. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Literal + +# My utils: +from utils_v2.string import regex + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# RegEx Patterns: +REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class ChatAlertRequestHeaders(BaseModel): + + sessionToken: str = Field( + description = "the session token of the user who is requesting the service", + pattern = REGEX_SESSION_TOKEN, + frozen = True, + alias = "X-Session-Token" + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + + def model_dump(self, *args, **kwargs): + return super().model_dump(*args, by_alias = True, **kwargs) + + +# --------------------------------------------------------------------------------------------------------------------- + + +class ChatAlertRequestData(BaseModel): + + message: str = Field( + description = "the message that needs to be sent to the admins", + frozen = True + ) + + format: Literal["plaintext", "markdown", "html"] = Field( + description = "the format in which the message was sent", + default = "markdown", + frozen = True + ) + + type: Literal["info", "warning", "error"] = Field( + description = "the type of alert that is being sent out", + default = "info", + frozen = True + ) + + chatId: str = Field( + description = "the identifier of the chat to send the message to", + default = None, + frozen = True + ) + + chatClient: Literal["telegram", "whatsapp"] = Field( + description = "the service to send the message through", + default = "telegram", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("format", "type", "chatClient", mode = "before") + def to_lowercase(cls, value): + if isinstance(value, str): value = value.strip().lower() + return value + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass