From 5f493f0ba9ee5548d86c7df73f14f7f0489981a2 Mon Sep 17 00:00:00 2001 From: yatmesh Date: Fri, 4 Jul 2025 17:34:01 +0530 Subject: [PATCH] (20250704) - Created Oauth Authentication for google places api with all files --- api/blueprints/software/oauth/__init__.py | 0 api/blueprints/software/oauth/callback_v2.py | 281 +++++++ api/blueprints/software/oauth/request_v2.py | 229 ++++++ api/main.py | 26 + .../software/google_places/__init__.py | 0 controllers_v2/software/google_places/base.py | 266 +++++++ .../software/google_places/google_places.py | 458 +++++++++++ models/api/software/places/__init__.py | 0 models/api/software/places/oauth.py | 135 ++++ models/core/auth_token.py | 2 +- models/software/places/__init__.py | 0 models/software/places/oauth.py | 185 +++++ utils_v2/goog/controllers/base.py | 72 +- .../goog/controllers/gmail/gmail_client.py | 6 +- utils_v2/goog/controllers/places/__init__.py | 0 .../goog/controllers/places/places_client.py | 752 ++++++++++++++++++ views_v2/software/__init__.py | 0 views_v2/software/places/__init__.py | 0 views_v2/software/places/oauth/__init__.py | 0 .../places/oauth/oauth_cancelled_v2.html | 114 +++ .../places/oauth/oauth_failure_v2.html | 115 +++ .../places/oauth/oauth_success_v2.html | 113 +++ 22 files changed, 2750 insertions(+), 4 deletions(-) create mode 100644 api/blueprints/software/oauth/__init__.py create mode 100644 api/blueprints/software/oauth/callback_v2.py create mode 100644 api/blueprints/software/oauth/request_v2.py create mode 100644 controllers_v2/software/google_places/__init__.py create mode 100644 controllers_v2/software/google_places/base.py create mode 100644 controllers_v2/software/google_places/google_places.py create mode 100644 models/api/software/places/__init__.py create mode 100644 models/api/software/places/oauth.py create mode 100644 models/software/places/__init__.py create mode 100644 models/software/places/oauth.py create mode 100644 utils_v2/goog/controllers/places/__init__.py create mode 100644 utils_v2/goog/controllers/places/places_client.py create mode 100644 views_v2/software/__init__.py create mode 100644 views_v2/software/places/__init__.py create mode 100644 views_v2/software/places/oauth/__init__.py create mode 100644 views_v2/software/places/oauth/oauth_cancelled_v2.html create mode 100644 views_v2/software/places/oauth/oauth_failure_v2.html create mode 100644 views_v2/software/places/oauth/oauth_success_v2.html diff --git a/api/blueprints/software/oauth/__init__.py b/api/blueprints/software/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/blueprints/software/oauth/callback_v2.py b/api/blueprints/software/oauth/callback_v2.py new file mode 100644 index 0000000..0be4e36 --- /dev/null +++ b/api/blueprints/software/oauth/callback_v2.py @@ -0,0 +1,281 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 16th Jan., 2025. + + OBJECTIVE: + + To receive callbacks (webhooks). + + 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, g, request, render_template + +# My utils: +from utils_v2.string import json +from utils_v2.logging.context import AsyncLoggerContext +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 +) + +# GMail-related utils: +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT + +# Data Models: +from models.core.auth_token import CoreAuthTokenModel +from models.software.places.oauth import OAuthPlacesHandleCallbackResponse + +# Common: +from shared import constants + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +places_oauth_callback_bp = Blueprint("places_cb", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@places_oauth_callback_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 + + +# --------------------------------------------------------------------------------------------------------------------- + + +@AsyncLoggerContext.log_it( + api_version = "1.0.0", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "googleplacesOAuthClbk", + log_input = 2, + log_output = 1, + sensitive_keys = ["sessionToken", "X-Session-Token"] +) +async def handle_places_callback( + client_controller, + client_connector, + request_url: str, + inbound_data: dict, +) -> OAuthPlacesHandleCallbackResponse: + + """ + This function has been kept separate only for convenience of logging. + :param client_controller: The mail controller instance. + :param client_connector: The instance of the third-party client to send to the mail controller. + :param request_url: The full request URL that came in. + :param inbound_data: The data received in the request. + :return: The client's response. + """ + + return await client_controller.handle_authorization_callback( + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, + client = client_connector, + request_url = request_url, + inbound_data = inbound_data, + session_token = None + ) + + +# --------------------------------------------------------------------------------------------------------------------- + + +@places_oauth_callback_bp.route("/callback/", methods = ["POST", "GET"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "googlePlacesOAuthClbkApi", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@handle_cancelled_request() +async def places_auth_callback( + places_client: str = None, + inbound_headers: dict = None, + inbound_data: dict = None, + inbound_files: dict = None, + **kwargs +): + + """ + This is the callback received when authorizing someone's mail client. + :param places_client: The mail company/brand that you want the authorization from. + :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. + """ + + # ┳┓ ┳┳┓ •┓ ┏┓┓• + # ┣┫┏┓┓┏╋┏┓ ╋┏┓ ┃┃┃┏┓┓┃ ┃ ┃┓┏┓┏┓╋ + # ┛┗┗┛┗┻┗┗ ┗┗┛ ┛ ┗┗┻┗┗ ┗┛┗┗┗ ┛┗┗ + + # Start by assuming failure: + client_controller = None + client_connector = None + client_response = None + exception = None + + places_client = { + "googleplaces": "googlePlaces" + }.get(places_client.lower(), places_client) + + # Figure out the client connector: + match places_client: + case "googlePlaces": client_controller, client_connector = current_app.places_controller, current_app.google_places_client + case _: client_controller, client_connector = None, None + + # Invoke the mail client: + if client_controller is not None and client_connector is not None: + try: client_response = await handle_places_callback( + client_controller, + client_connector, + request_url = request.url, + inbound_data = inbound_data + ) + except Exception as excp: exception = excp + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Make the mail client a label: + places_client = { + "googlePlaces": "Google Places" + }.get(places_client, places_client) + + # If there was some exception: + if exception: return await render_template( + "/software/places/oauth/oauth_failure_v2.html", + client = places_client, + failure_hint = ( + f"An internal server error occurred. " + f"Please use log-id '{kwargs.get('log_id')}' to check with the support team." + ) + ) + + # For an invalid client: + if client_controller is None or client_connector is None: + return await render_template( + "/software/places/oauth/oauth_failure_v2.html", + client = places_client, + failure_hint = ( + f"Invalid client '{places_client}' selected. " + f"Please use log-id '{kwargs.get('log_id')}' to check with the support team." + ) + ) + + # For a valid client whose authorization was denied/cancelled: + if client_response.action in ["denied", "cancelled"]: + return await render_template( + "/software/places/oauth/oauth_cancelled_v2.html", + client = places_client + ) + + # For successful authorization: + if client_response.success: + return await render_template( + "/software/places/oauth/oauth_success_v2.html", + client = places_client + ) + + # For failed authorization: + return await render_template( + "/software/places/oauth/oauth_failure_v2.html", + client = places_client, + failure_hint = ( + f"{client_response.message} " + f"Please use log-id '{kwargs.get('log_id')}' to check with the support team.".strip() + ) + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/blueprints/software/oauth/request_v2.py b/api/blueprints/software/oauth/request_v2.py new file mode 100644 index 0000000..349eae9 --- /dev/null +++ b/api/blueprints/software/oauth/request_v2.py @@ -0,0 +1,229 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 16th jan., 2025. + + OBJECTIVE: + + To receive authorization requests (OAuth2.0) for various mail providers and accordingly respond with the + authorization request URLs. + + 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, request + +# My utils: +from utils_v2.string import json +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 +) + +# GMail-related utils: +from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT + +# Common: +from shared import constants + +# Data Models: +from models.core.user import CoreUserInfoModel +from models.api.software.places.oauth import ( + OAuthPlacesAuthorizationRequestHeaders, + OAuthPlacesAuthorizationRequestData +) +from models.core.auth_token import CoreAuthTokenModel + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +places_oauth_request_bp = Blueprint("places_oauth", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@places_oauth_request_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 + + +# --------------------------------------------------------------------------------------------------------------------- + + +@places_oauth_request_bp.route("/oauth", 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") +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "placesOAuthUrlReqApi", + log_input = True, + log_output = True, + sensitive_keys = ["sessionToken", "X-Session-Token"] +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@validate_input( + header_validator = lambda x: OAuthPlacesAuthorizationRequestHeaders(**x).model_dump(), + data_validator = lambda x: OAuthPlacesAuthorizationRequestData(**x) +) +@handle_cancelled_request() +async def request_oauth_authorization_url( + inbound_headers: dict | OAuthPlacesAuthorizationRequestHeaders = None, + inbound_data: dict | OAuthPlacesAuthorizationRequestData = None, + inbound_files: dict = None, + **kwargs +): + + """ + Use this when requesting access to someone's GMail account. This API should be used from the UI. A button click + "Connect to GMail" should hit this API, which will generate a request to gain access to the user's GMail account. + When the URL is hit, it opens Google's own UI, and, when the user clicks "Continue", Google hits your 'redirect_url' + to inform you about the user's action. + :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. + """ + + # ┏┓ + # ┃┃┏┓┏┓┏┓┏┓┏┓┏┏┓┏┏ + # ┣┛┛ ┗ ┣┛┛ ┗┛┗┗ ┛┛ + # ┛ + + # If the session token is invalid/expired: + if kwargs.get("session_info") is None: + return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED + ) + + # ┳┓ ┳┳┓ •┓ ┏┓┓• + # ┣┫┏┓┓┏╋┏┓ ╋┏┓ ┃┃┃┏┓┓┃ ┃ ┃┓┏┓┏┓╋ + # ┛┗┗┛┗┻┗┗ ┗┗┛ ┛ ┗┗┻┗┗ ┗┛┗┗┗ ┛┗┗ + + # Start by assuming failure: + client_controller = None + client_connector = None + client_response = None + + # Figure out the client connector: + match inbound_data.client: + case "googlePlaces": client_controller, client_connector = current_app.places_controller, current_app.google_places_client + case _: client_controller, client_connector = None, None + + # If a client connector was matched: + if client_controller is not None and client_connector is not None: + client_response = await client_controller.get_authorization_url( + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, + client = client_connector, + user_info = CoreUserInfoModel(**kwargs["session_info"]), + inbound_data = inbound_data, + session_token = inbound_headers["X-Session-Token"] + ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Unknown client: + if client_controller is None or client_connector is None: return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.BAD_REQUEST, + message = f"Unknown/unimplemented client '{inbound_data.client}'." + ) + + # Known client (could be a success or a failure): + return ResponseModel( + status_code = StatusCodes.OK if client_response.success else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if client_response.success else HttpCodes.INTERNAL_SERVER_ERROR, + message = client_response.message, + data = { + "client": inbound_data.client, + "authorizationUrl": client_response.url + } if client_response.success else None + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/main.py b/api/main.py index 894b189..73d5204 100644 --- a/api/main.py +++ b/api/main.py @@ -62,6 +62,9 @@ from utils_v2.api.async_quart import ( # GMail-related utils: from utils_v2.goog.controllers.gmail.gmail_client import AsyncGmailClient +# Google Places Utils +from utils_v2.goog.controllers.places.places_client import AsyncPlacesClient + # Chat clients: from utils_v2.whatsapp.nimbus.controllers.async_nimbus_whatsapp import AsyncNimbusWhatsapp @@ -86,6 +89,7 @@ from controllers_v2.message.sms.savvy_bulk_sms_kenya import SavvyBulkSMSKenyaCon # --- from controllers_v2.message.mail.all_mail import AllMailController from controllers_v2.message.mail.gmail import GmailController +from controllers_v2.software.google_places.google_places import PlacesController # --- from controllers_v2.message.chat.all_chat import AllChatController from controllers_v2.message.chat.whatsapp_nimbus import WhatsAppNimbusController @@ -136,6 +140,8 @@ from api.blueprints.message.chat.tags import chat_update_tags_bp # Software Blueprints: from api.blueprints.software.auth import sw_auth_bp +from api.blueprints.software.oauth.request_v2 import places_oauth_request_bp +from api.blueprints.software.oauth.callback_v2 import places_oauth_callback_bp # Finstitutions / Payment Blueprints: from api.blueprints.finstitutions.payments.auth_v2 import pg_auth_bp @@ -216,6 +222,8 @@ app.register_blueprint(chat_update_tags_bp, url_prefix = f"/{MODULE_BASE}/chat") # Software Blueprints: app.register_blueprint(sw_auth_bp, url_prefix = f"/{MODULE_BASE}/software") +app.register_blueprint(places_oauth_request_bp, url_prefix = f"/{MODULE_BASE}/software") +app.register_blueprint(places_oauth_callback_bp, url_prefix = f"/{MODULE_BASE}/software") # Finstitutions / Payment Blueprints: app.register_blueprint(pg_auth_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") @@ -519,6 +527,14 @@ async def app_startup(**kwargs): ) current_app.printer("Message/Mail (C) ready.") + current_app.places_controller = PlacesController( + cache=current_app.module_cache, + http_client=current_app.http_client, + alert_url=current_app.script_data["alerts"]["url"], + debug=enable_debugging + ) + current_app.printer("GooglePlaces (C) ready.") + # Messages / Chat Controllers: current_app.chat_controller = AllChatController( cache = current_app.module_cache, @@ -635,6 +651,16 @@ async def app_startup(**kwargs): debug_only_errors = False ) + current_app.google_places_client = AsyncPlacesClient( + service_name = "googlePlacesApi", + oauth_json = script_cred["google"]["oauth"]["places"], + http_client = current_app.http_client, + redirect_url = r"https://api.thecaoffice.com/converse/software/callback/googleplaces", + debug = enable_debugging, + debug_prefix = "Google Places (C) | ", + debug_only_errors = False + ) + current_app.printer("Connectors and Clients ready.") # ┏┓┳ ┳┳┓ • diff --git a/controllers_v2/software/google_places/__init__.py b/controllers_v2/software/google_places/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers_v2/software/google_places/base.py b/controllers_v2/software/google_places/base.py new file mode 100644 index 0000000..22c9bf0 --- /dev/null +++ b/controllers_v2/software/google_places/base.py @@ -0,0 +1,266 @@ +""" + + AUTHOR: + + Omkar Khandare + + DATE: + + Friday, 27th Jun., 2025. + + OBJECTIVE: + + To handle ecommerce authentication. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +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 +from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache + +# Controllers: +from controllers_v2.core.software import CoreSoftwareController + +# To make very controlled API calls: +from utils_v2.rest.controllers.async_base import AsyncREST +from utils_v2.rest.models.api_call import ApiResponse + +# Models: +# Models: +from models.core.user import CoreUserInfoModel +from models.core.auth_token import CoreAuthTokenModel +from models.core.user import CoreUserInfoModel +from models.api.message.mail.oauth import ( + OAuthMailAuthorizationRequestHeaders, + OAuthMailAuthorizationRequestData +) +from models.message.mail.oauth import OAuthMailGetAuthorizationURLResponse, OAuthMailHandleCallbackResponse +from models.core.message import CoreMessageModel +# To make HTTP requests: +import httpx + +# to work with MongoDB: +from bson.objectid import ObjectId + +# Mail Client(s): +from utils_v2.goog.controllers.places.places_client import AsyncPlacesClient + +# To make abstract classes: +from abc import ABC, abstractmethod + + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class GooglePlacesController(CoreSoftwareController, ABC): + + # ┏┓┓ ┓┏ + # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ + # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ + + SERVICE_TYPE = "software" + + # ┏┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ + + 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 = "Google Places (C) | ", + debug_only_errors: bool = True + ): + + """ + This is the foundational controller for generate an authentication key for ecommerce integration, + To Validate user from ecommerce webhooks + :param cache: The object to use for caching results from database calls. + :param http_client: The HTTP client to use to make REST-ful API calls. + :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: + shopify_filter = {} + for k, v in (base_filter or {}).items(): shopify_filter[k] = v + shopify_filter["serviceType"] = self.SERVICE_TYPE + + # Invoke the parent's constructor: + CoreSoftwareController.__init__( + self, + cache = cache, + alert_url = alert_url, + http_client = http_client, + base_filter = shopify_filter, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + + # Init a variable in a parent: + self._service_type = self.SERVICE_TYPE + + # For controlled REST-ful calls: + self._rest = AsyncREST( + http_client = http_client, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + + # ┏┓┏┓ ┓ ┏┓ ┏┓ + # ┃┃┣┫┓┏╋┣┓┏┛ ┃┫ + # ┗┛┛┗┗┻┗┛┗┗━•┗┛ + + @abstractmethod + async def get_authorization_url( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncPlacesClient, + user_info: CoreUserInfoModel, + inbound_data: OAuthMailAuthorizationRequestData, + session_token: str + ) -> OAuthMailGetAuthorizationURLResponse: + """ + To accept an incoming request for mail integration and provide a URL that the user can use to authorize your + service to access his mail inbox. + :param sql_conn: The database connection to use to perform this task. + :param mongo_data_conn: The database connection to use to perform this task. + :param mail_client: The instance of the third-party mail client that will be used to get the URL. + :param user_info: The information about your user who is trying to use this system. + :param inbound_data: The data that came in with the request (API call). + :param session_token: The session token of the user. + :return: A structure response with details about the URL generation process. + """ + + pass + + @abstractmethod + async def handle_authorization_callback( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncPlacesClient, + request_url: str, + inbound_data: dict, + session_token: str = None + ) -> OAuthMailHandleCallbackResponse: + """ + To handle the authorization callback for the mail client. The user may grant or deny authorization. + :param sql_conn: The database connection to use to perform this task. + :param mongo_data_conn: The database connection to use to perform this task. + :param mail_client: The instance of the third-party mail client that will be used to get the URL. + :param request_url: The full callback URL invoked by the third-party client. + :param inbound_data: The data that came in with the request (API call). + :param session_token: The session token of the user. It is expected that this will be null in all cases. + :return: A structured response of the process of handling the mail callback. + """ + + pass + + @abstractmethod + async def refresh_authorization( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncPlacesClient, + http_client: httpx.AsyncClient, + auth_token: CoreAuthTokenModel, + force_refresh: bool = False, + session_token: str = None + ) -> CoreAuthTokenModel: + """ + To refresh the third-party client's access/authorization token(s) before use. + :param sql_conn: The database connection to use when storing the refreshed tokens. + :param mongo_data_conn: The database connection to use when storing the refreshed tokens. + :param mail_client: The connection of the third-party mail client. + :param http_client: The HTTP client to use to make the token refresh request. + :param auth_token: The auth-token model of the existing integration. This may get updated if a refresh is needed + (or forced). + :param force_refresh: Whether, or not, you would like to force a refresh even if the token hasn't expired yet. + :param session_token: The session token of the user. This will be null if this method is invoked by a cron + script in the background. Needed only to identify the user in case of a failure to send a timely alert. + :return: The same auth-token model instance, but maybe with updated tokens. + """ + + pass + + + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/controllers_v2/software/google_places/google_places.py b/controllers_v2/software/google_places/google_places.py new file mode 100644 index 0000000..21aac74 --- /dev/null +++ b/controllers_v2/software/google_places/google_places.py @@ -0,0 +1,458 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 16th Jan., 2025. + + OBJECTIVE: + + To handle all mail-related behaviour for Gmail from one place. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# My async utils: +from utils_v2.string import json +from utils_v2.mail import mail_parser +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 +from utils_v2.logging.context import AsyncLoggerContext + +# Controllers: +from controllers_v2.software.google_places.base import GooglePlacesController + +# Models: +from models.core.user import CoreUserInfoModel +from models.core.auth_token import CoreAuthTokenModel +from models.core.message import CoreMessageModel +from models.api.software.places.oauth import ( + OAuthPlacesAuthorizationRequestHeaders, + OAuthPlacesAuthorizationRequestData +) +from models.software.places.oauth import OAuthPlacesGetAuthorizationURLResponse, OAuthPlacesHandleCallbackResponse + +# Mail Client(s): +from utils_v2.goog.controllers.places.places_client import AsyncPlacesClient, SCOPES_PLACES_FULL +from utils_v2.goog.models.auth_tokens import GoogleAuthTokens + +# Shared: +from shared import constants + +# To work with datatypes: +from typing import List, Any + +# To work with MongoDB: +from bson import ObjectId +from pymongo import InsertOne, UpdateOne, ReplaceOne + +# To work with LLMs: +from controllers.core.ai.llm import CoreLLMController +from models.core.ai.llm import LLMInput, LLMOutput + +# To make HTTP requests: +import httpx + +# To work with date and time: +import datetime + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class PlacesController(GooglePlacesController): + + # ┏┓┓ ┓┏ + # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ + # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ + + CLIENT_NAME = "googlePlaces" + + # ┏┓ + # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ + # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ + + def __init__( + self, + cache: AsyncRedisCache = None, + http_client: httpx.AsyncClient = None, + alert_url: str = None, + debug: bool = True, + debug_prefix: str = "G Places (C) | ", + debug_only_errors: bool = True + ): + + """ + This is the controller specifically built for Gmail's services. It is built on top of the base mail controller. + :param cache: The object to use for caching results from database calls. + :param http_client: The HTTP client + :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. + """ + + # Invoke the parent's constructor: + super().__init__( + cache = cache, + alert_url = alert_url, + http_client = http_client, + base_filter = {"client": self.CLIENT_NAME}, + debug = debug, + debug_prefix = debug_prefix, + debug_only_errors = debug_only_errors + ) + + # Init a variable in a parent: + self._client = self.CLIENT_NAME + + # ┏┓┏┓ ┓ ┏┓ ┏┓ + # ┃┃┣┫┓┏╋┣┓┏┛ ┃┫ + # ┗┛┛┗┗┻┗┛┗┗━•┗┛ + + async def get_authorization_url( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + client: AsyncPlacesClient, + user_info: CoreUserInfoModel, + inbound_data: OAuthPlacesAuthorizationRequestData, + session_token: str + ) -> OAuthPlacesGetAuthorizationURLResponse: + + """ + To accept an incoming request for mail integration and provide a URL that the user can use to authorize your + service to access his mail inbox. + :param sql_conn: The database connection to use to perform this task. + :param mongo_data_conn: The database connection to use to perform this task. + :param client: The instance of the third-party mail client that will be used to get the URL. + :param user_info: The information about your user who is trying to use this system. + :param inbound_data: The data that came in with the request (API call). + :param session_token: The session token of the user. + :return: A structure response with details about the URL generation process. + """ + + # Start by assuming failure: + response = OAuthPlacesGetAuthorizationURLResponse() + + # First, we create/update a record for this integration request: + token_key = await self.generate_token_key( + sql_conn = sql_conn, + mongo_data_conn = mongo_data_conn, + auth_token = CoreAuthTokenModel( + serviceType = "software", + client = inbound_data.client, + authType = "oauth", + user = user_info, + clientUserId = {"email": inbound_data.mailId}, + status = "pending", + # syncFreq = inbound_data.syncFreq, + ), + token_notes = { + "email": inbound_data.mailId + }, + display_name = inbound_data.mailId, + display_picture = None, + session_token = session_token + ) + + # If generating the token key fails: + if token_key is None: + response.message = "Failed to generate token key." + return response + + # Now we create the URL: + response.url = await client.get_authorization_url( + scopes = SCOPES_PLACES_FULL, + state = str(token_key), + access_type = "offline", + approval_prompt = "force", + include_granted_scopes = "true", + user_email = inbound_data.mailId + ) + response.success = True + response.message = "Please use the URL to integrate your Places account." + + # Done here: + return response + + async def handle_authorization_callback( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + client: AsyncPlacesClient, + request_url: str, + inbound_data: dict, + session_token: str = None + ) -> OAuthPlacesHandleCallbackResponse: + + """ + To handle the authorization callback for the mail client. The user may grant or deny authorization. + :param sql_conn: The database connection to use to perform this task. + :param mongo_data_conn: The database connection to use to perform this task. + :param client: The instance of the third-party mail client that will be used to get the URL. + :param request_url: The full callback URL invoked by the third-party client. + :param inbound_data: The data that came in with the request (API call). + :param session_token: The session token of the user. It is expected that this will be null in all cases. + :return: A structured response of the process of handling the mail callback. + """ + + # Start by assuming failure: + response = OAuthPlacesHandleCallbackResponse() + + # In case the user denied access: + if inbound_data.get("error") == "access_denied": + response.action = "denied" + response.message = "The user denied authorization." + return response + + # Otherwise we know that the user authorized access: + else: + response.action = "authorized" + response.message = "The user has given authorization." + + # We fetch the auth-token associated with this authorization loop: + auth_token = await self.get_token_from_key( + mongo_data_conn = mongo_data_conn, + token_key = inbound_data["state"], + must_be_active = False + ) + if not auth_token: + response.action = "unknown" + response.message = "Failed to load the auth-token for this flow." + return response + + # Generate the tokens from the callback. Google sends all the needed params in the callback as the URL's query + # params. We can simply use the exact URL that was hit to generate the tokens. In Quart (and Flask) this can be + # achieved by 'request.url' like this: + google_tokens = await client.get_authorization_tokens( + redirect_url = request_url, + scopes = None + ) + + # If no tokens were generated: + if not google_tokens: + response.message = "Failed to get access token(s) from Places." + return response + + # Try getting the user's profile from Gmail: + user_profile = await client.get_user_profile(tokens=google_tokens) + if user_profile.success: + google_tokens.email = user_profile.data["emailAddress"] + google_tokens.displayName = user_profile.data["displayName"] + google_tokens.displayPictureUrl = user_profile.data["displayPictureUrl"] + else: + response.message = "Failed to get the user's profile from Gmail." + return response + + # We confirm if the expected email account and the one that gave authorization are the same: + if auth_token.clientUserId["email"] != str(google_tokens.email): + response.message = ( + f"We were expecting authorization from '{auth_token.clientUserId['email']}', " + f"but got authorization from '{google_tokens.email}' instead." + ) + return response + + # Now that we have passed the check, + # we save the tokens to the database: + auth_token.clientUserId = google_tokens.client_user_id + auth_token.token = google_tokens.model_dump() + auth_token.status = "active" + tokens_saved = await self.set_token( + sql_conn = sql_conn, + mongo_data_conn = mongo_data_conn, + token_key = inbound_data["state"], + auth_token = auth_token, + token_notes = { + "email": google_tokens.email + }, + display_name = google_tokens.displayName, + display_picture = google_tokens.displayPictureUrl, + session_token = session_token + ) + + # Note down the final result: + if tokens_saved: + response.success = True + response.message = "Authorization flow completed successfully." + else: response.message = "Failed to save the token(s)." + + # Done here: + return response + + async def refresh_authorization( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncPlacesClient, + http_client: httpx.AsyncClient, + auth_token: CoreAuthTokenModel, + force_refresh: bool = False, + session_token: str = None + ) -> CoreAuthTokenModel: + """ + To refresh the third-party client's access/authorization token(s) before use. + :param sql_conn: The database connection to use when storing the refreshed tokens. + :param mongo_data_conn: The database connection to use when storing the refreshed tokens. + :param mail_client: The connection of the third-party mail client. + :param http_client: The HTTP client to use to make the token refresh request. + :param auth_token: The auth-token model of the existing integration. This may get updated if a refresh is needed + (or forced). + :param force_refresh: Whether, or not, you would like to force a refresh even if the token hasn't expired yet. + :param session_token: The session token of the user. This will be null if this method is invoked by a cron + script in the background. Needed only to identify the user in case of a failure to send a timely alert. + :return: The same auth-token model instance, but maybe with updated tokens. + """ + + raise NotImplementedError + + # @AsyncLoggerContext.log_it( + # api_version = "1.0.0", + # project = constants.PROJECT_NAME, + # log_type = constants.MODULE_NAME, + # operation = "gmailTokRefresh", + # log_input = False, + # log_output = False, + # sensitive_keys = ["session_token"] + # ) + # async def refresh_authorization( + # self, + # sql_conn: AsyncMySQL, + # mongo_data_conn: AsyncMongo, + # mail_client: AsyncGmailClient, + # http_client: httpx.AsyncClient, + # auth_token: CoreAuthTokenModel, + # force_refresh: bool = False, + # session_token: str = None + # ) -> CoreAuthTokenModel: + # + # """ + # To refresh the third-party client's access/authorization token(s) before use. + # :param sql_conn: The database connection to use when storing the refreshed tokens. + # :param mongo_data_conn: The database connection to use when storing the refreshed tokens. + # :param mail_client: The connection of the third-party mail client. + # :param http_client: The HTTP client to use to make the token refresh request. + # :param auth_token: The auth-token model of the existing integration. This may get updated if a refresh is needed + # (or forced). + # :param force_refresh: Whether, or not, you would like to force a refresh even if the token hasn't expired yet. + # :param session_token: The session token of the user. This will be null if this method is invoked by a cron + # script in the background. Needed only to identify the user in case of a failure to send a timely alert. + # :return: The same auth-token model instance, but maybe with updated tokens. + # """ + # + # # Extract the client's tokens from the full token payload given by the database: + # google_tokens = GoogleAuthTokens(**auth_token.token) + # + # # Refresh the tokens (if/as needed): + # tokens_refreshed = await google_tokens.arefresh( + # http_client = http_client, + # client_id = mail_client.client_id, + # client_secret = mail_client.client_secret, + # force_refresh = force_refresh + # ) + # + # # If the tokens were refreshed: + # if tokens_refreshed: + # + # # Try getting the user's profile from Gmail: + # user_profile = await mail_client.get_user_profile(tokens = google_tokens) + # if user_profile.success: + # google_tokens.email = user_profile.data["emailAddress"] + # google_tokens.displayName = user_profile.data["displayName"] + # google_tokens.displayPictureUrl = user_profile.data["displayPictureUrl"] + # + # # Update the existing auth-token model: + # auth_token.token = google_tokens.model_dump() + # auth_token.lastRefreshTs = date_time.get_current_utc_date_time(as_string = True) + # + # # Try to update the record in the database: + # await self.set_token( + # sql_conn = sql_conn, + # mongo_data_conn = mongo_data_conn, + # token_key = auth_token.key, + # auth_token = auth_token, + # token_notes = { + # "email": auth_token.clientUserId.get("email"), + # "client": auth_token.client + # }, + # display_name = google_tokens.email, + # display_picture = google_tokens.displayPictureUrl, + # session_token = session_token, + # ) + # + # # Whether refreshed, or not, return the auth-token model: + # return auth_token + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/api/software/places/__init__.py b/models/api/software/places/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/api/software/places/oauth.py b/models/api/software/places/oauth.py new file mode 100644 index 0000000..bdd0949 --- /dev/null +++ b/models/api/software/places/oauth.py @@ -0,0 +1,135 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Wednesday, 27th Nov., 2024. + + OBJECTIVE: + + To provide the structure for the request and response of the APIs that will be used to request OAuth2.0 + authorization for mail services. + + 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 OAuthPlacesAuthorizationRequestHeaders(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 OAuthPlacesAuthorizationRequestData(BaseModel): + client: Literal["googlePlaces"] = Field( + description="the data provider like google places", + frozen=True + ) + + mailId: str = Field( + description = "the e-mail id that the user intends to authorize", + pattern = regex.REGEX_EMAIL_ID, + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + pass + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/core/auth_token.py b/models/core/auth_token.py index 0b03660..87e7ed6 100644 --- a/models/core/auth_token.py +++ b/models/core/auth_token.py @@ -111,7 +111,7 @@ class CoreAuthTokenModel(BaseModel): "nimbusSmsIndia", "savvyBulkSmsKenya", # ........................ SMS Clients "razorpay", "safaricomMPesaExpress", # .......................... Payment Gateways "zerodhaKite", "iciciBreeze", "paperTrading", # ................. Stock Brokers - "theCaOfficeAi", "mikrotikPPPoE1000", "mikrotikHotspot1000", # ... Software + "theCaOfficeAi", "mikrotikPPPoE1000", "mikrotikHotspot1000", "googlePlaces", # ... Software "shopify" ] = Field( description = "the third-part client that was used", diff --git a/models/software/places/__init__.py b/models/software/places/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/software/places/oauth.py b/models/software/places/oauth.py new file mode 100644 index 0000000..51c61ba --- /dev/null +++ b/models/software/places/oauth.py @@ -0,0 +1,185 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Thursday, 16th Jan., 2025. + + OBJECTIVE: + + To provide the structure for the request and response of the APIs that will be used to request OAuth2.0 + authorization for mail services. + + 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, Any + +# My utils: +from utils_v2.string import regex + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class OAuthPlacesGetAuthorizationURLResponse(BaseModel): + + success: bool = Field( + description = "To indicate whether or not, the action was a success", + frozen = False, + default = False + ) + + url: str | None = Field( + description = "The URL to use to integrate the mail client.", + frozen = False, + default = None + ) + + message: str = Field( + description = "To explain what happened in the process generating a URL.", + frozen = False, + default = "ERR: Message not captured." + ) + + exception: Any = Field( + description = "To pass on any exception that occurred in the process.", + frozen = False, + default = None + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┏┓ ┏┓ + # ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏ + # ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛ + + pass + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +class OAuthPlacesHandleCallbackResponse(BaseModel): + + success: bool = Field( + description = "To indicate whether or not, the action was a success", + frozen = False, + default = False + ) + + action: Literal[ + "unknown", # ..... Initial value when the user's intent is not known. + "denied", # ...... The user consciously denied permission. + "cancelled", # ... The user cancelled the process midway. + "authorized" # ... The user gave authorization. + ] = Field( + description = "To describe what the user did with the authorization URL.", + frozen = False, + default = "unknown" + ) + + message: str = Field( + description = "To explain what happened in the process of handling the OAuth callback.", + frozen = False, + default = "ERR: Message not captured." + ) + + exception: Any = Field( + description = "To pass on any exception that occurred in the process.", + frozen = False, + default = None + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┏┓ ┏┓ + # ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏ + # ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛ + + pass + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + pass + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/utils_v2/goog/controllers/base.py b/utils_v2/goog/controllers/base.py index 62cbdfc..550aa68 100644 --- a/utils_v2/goog/controllers/base.py +++ b/utils_v2/goog/controllers/base.py @@ -247,13 +247,27 @@ class AsyncGoogleBase: # Get the credentials: credentials = flow.fetch_token(authorization_response = redirect_url) ttl = credentials["expires_in"] - 60 - return GoogleAuthTokens( + tokens = GoogleAuthTokens( accessToken = credentials["access_token"], refreshToken = credentials["refresh_token"], expiresAt = date_time.get_current_utc_date_time() + datetime.timedelta(seconds = ttl), scopes = credentials["scope"] ) + # Fetch the user's info: + if not self._debug_only_errors: self._printer("Getting User Profile.") + api_response = await self.get( + url = f"https://openidconnect.googleapis.com/v1/userinfo", + headers = {"Authorization": f"Bearer {tokens.accessToken}"} + ) + if api_response.httpCode in [200]: + api_response_json = await api_response.get_json() + tokens.displayName = api_response_json.get("name") + tokens.displayPictureUrl = api_response_json.get("picture") + + # Done here: + return tokens + # ┏┓ ┳┓ ┓• # ┣ ┏┓┏┓┏┓┏┓ ┃┃┏┓┏┏┓┏┫┓┏┓┏┓ # ┗┛┛ ┛ ┗┛┛ ┻┛┗ ┗┗┛┗┻┗┛┗┗┫ @@ -476,4 +490,58 @@ class AsyncGoogleBase: if __name__ == "__main__": - pass + from utils_v2.string import json + import asyncio + + # Create an HTTP client: + test_client = httpx.AsyncClient( + limits = httpx.Limits( + max_connections = 100, # ............ Maximum number of connections allowed in the pool. + max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. + ), + timeout = httpx.Timeout( + connect = 2.5, # ... Shorter connection timeout. + read = 2.5, # ...... Like what EasyEcom gives. + write = 10.0, # .... Time to wait for sending data. + pool = 120.0 # ..... Time to wait for a free connection from the pool. + ) + ) + + # Define some scopes that the app will use: + TEST_SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.profile" + ] + + # Read the secrets that give you access to the app: + secrets_file = r"C:\Users\Khushal P Soonderji\Downloads\google_places_test_secret.json" + secrets_dict = json.from_file(secrets_file) + + async def main(): + + # Create an instance of the client: + my_google = AsyncGoogleBase( + service_name = "Goog", + oauth_json = secrets_dict, + http_client = test_client, + redirect_url = r"https://wtt.ditscentre.in/shopify/test/1", + debug = True, + debug_prefix = "Goog (C) | ", + debug_only_errors = False + ) + + # Request Auth: + print("AUTH URL:", await my_google.get_authorization_url( + scopes = TEST_SCOPES, + state = "Bhopli", + approval_prompt = "force" + )) + + # Get tokens from callback: + test_tokens = await my_google.get_authorization_tokens( + scopes = TEST_SCOPES, + redirect_url = input("Paste the redirect URL here: ") + ) + print("TOKENS:", json.to_string(test_tokens.model_dump(), default = str)) + + asyncio.run(main()) diff --git a/utils_v2/goog/controllers/gmail/gmail_client.py b/utils_v2/goog/controllers/gmail/gmail_client.py index f1e3bde..02961d7 100644 --- a/utils_v2/goog/controllers/gmail/gmail_client.py +++ b/utils_v2/goog/controllers/gmail/gmail_client.py @@ -131,6 +131,10 @@ SCOPES_GMAIL_FULL = [ class AsyncGmailClient(AsyncGoogleBase): + # ┳┳ ┏┓ ┏•┓ + # ┃┃┏┏┓┏┓ ┃┃┏┓┏┓╋┓┃┏┓ + # ┗┛┛┗ ┛ ┣┛┛ ┗┛┛┗┗┗ + async def get_user_profile( self, tokens: GoogleAuthTokens, @@ -138,7 +142,7 @@ class AsyncGmailClient(AsyncGoogleBase): ) -> GoogleApiResponse: """ - To get the list of labels of this user. + To get the details of the user that has logged in. DOCUMENTATION: 1. https://developers.google.com/gmail/api/reference/rest/v1/users/getProfile 2. https://developers.google.com/people/api/rest/v1/people/get diff --git a/utils_v2/goog/controllers/places/__init__.py b/utils_v2/goog/controllers/places/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils_v2/goog/controllers/places/places_client.py b/utils_v2/goog/controllers/places/places_client.py new file mode 100644 index 0000000..7ce5dbe --- /dev/null +++ b/utils_v2/goog/controllers/places/places_client.py @@ -0,0 +1,752 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 27th Jun., 2025 + + OBJECTIVE: + + To get location and business info from Google's Places API. + + REFERENCES: + + 1. GMail Quickstart: https://developers.google.com/gmail/api/quickstart/python + 2. GMail Labels: https://developers.google.com/gmail/api/guides/labels + 3. GMail Messages: https://developers.google.com/gmail/api/reference/rest/v1/users.messages + 4. People Profile: https://developers.google.com/people/api/rest/v1/people/get + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# System-level activities: +import io + +# My utils: +from utils_v2.string import json + +# My Google utils: +from utils_v2.goog.controllers.base import AsyncGoogleBase +from utils_v2.goog.models.auth_tokens import GoogleAuthTokens +from utils_v2.goog.models.api_call import GoogleApiResponse + +# Related to Google: +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +# To make API calls: +import httpx + +# For asynchronous activities: +import asyncio + +# To work with date and time: +import datetime + +# For working with datatypes: +from typing import Dict, Literal, List, Any + +# For debugging: +from icecream import IceCreamDebugger +import inspect + +# For computational help: +import math + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Google Scopes: +SCOPES_PLACES_FULL = [ + r"https://www.googleapis.com/auth/cloud-platform", + r"https://www.googleapis.com/auth/userinfo.profile", + r"https://www.googleapis.com/auth/gmail.metadata" +] + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class AsyncPlacesClient(AsyncGoogleBase): + + # The types of places: + PLACE_TYPES = { + "Automotive": [ + "car_dealer", + "car_rental", + "car_repair", + "car_wash", + "electric_vehicle_charging_station", + "gas_station", + "parking", + "rest_stop" + ], + "Business": [ + "corporate_office", + "farm", + "ranch" + ], + "Culture": [ + "art_gallery", + "art_studio", + "auditorium", + "cultural_landmark", + "historical_place", + "monument", + "museum", + "performing_arts_theater", + "sculpture" + ], + "Education": [ + "library", + "preschool", + "primary_school", + "secondary_school", + "university" + ], + "Entertainment and Recreation": [ + "adventure_sports_center", + "amphitheatre", + "amusement_center", + "amusement_park", + "aquarium", + "banquet_hall", + "barbecue_area", + "botanical_garden", + "bowling_alley", + "casino", + "childrens_camp", + "comedy_club", + "community_center", + "concert_hall", + "convention_center", + "cultural_center", + "cycling_park", + "dance_hall", + "dog_park", + "event_venue", + "ferris_wheel", + "garden", + "hiking_area", + "historical_landmark", + "internet_cafe", + "karaoke", + "marina", + "movie_rental", + "movie_theater", + "national_park", + "night_club", + "observation_deck", + "off_roading_area", + "opera_house", + "park", + "philharmonic_hall", + "picnic_ground", + "planetarium", + "plaza", + "roller_coaster", + "skateboard_park", + "state_park", + "tourist_attraction", + "video_arcade", + "visitor_center", + "water_park", + "wedding_venue", + "wildlife_park", + "wildlife_refuge", + "zoo" + ], + "Facilities": [ + "public_bath", + "public_bathroom", + "stable" + ], + "Finance": [ + "accounting", + "atm", + "bank" + ], + "Food and Drink": [ + "acai_shop", + "afghani_restaurant", + "african_restaurant", + "american_restaurant", + "asian_restaurant", + "bagel_shop", + "bakery", + "bar", + "bar_and_grill", + "barbecue_restaurant", + "brazilian_restaurant", + "breakfast_restaurant", + "brunch_restaurant", + "buffet_restaurant", + "cafe", + "cafeteria", + "candy_store", + "cat_cafe", + "chinese_restaurant", + "chocolate_factory", + "chocolate_shop", + "coffee_shop", + "confectionery", + "deli", + "dessert_restaurant", + "dessert_shop", + "diner", + "dog_cafe", + "donut_shop", + "fast_food_restaurant", + "fine_dining_restaurant", + "food_court", + "french_restaurant", + "greek_restaurant", + "hamburger_restaurant", + "ice_cream_shop", + "indian_restaurant", + "indonesian_restaurant", + "italian_restaurant", + "japanese_restaurant", + "juice_shop", + "korean_restaurant", + "lebanese_restaurant", + "meal_delivery", + "meal_takeaway", + "mediterranean_restaurant", + "mexican_restaurant", + "middle_eastern_restaurant", + "pizza_restaurant", + "pub", + "ramen_restaurant", + "restaurant", + "sandwich_shop", + "seafood_restaurant", + "spanish_restaurant", + "steak_house", + "sushi_restaurant", + "tea_house", + "thai_restaurant", + "turkish_restaurant", + "vegan_restaurant", + "vegetarian_restaurant", + "vietnamese_restaurant", + "wine_bar" + ], + "Geographical Areas": [ + "administrative_area_level_1", + "administrative_area_level_2", + "country", + "locality", + "postal_code", + "school_district" + ], + "Government": [ + "city_hall", + "courthouse", + "embassy", + "fire_station", + "government_office", + "police", + "post_office" + ], + "Health and Wellness": [ + "chiropractor", + "dental_clinic", + "dentist", + "doctor", + "drugstore", + "hospital", + "massage", + "medical_lab", + "pharmacy", + "physiotherapist", + "sauna", + "skin_care_clinic", + "spa", + "tanning_studio", + "wellness_center", + "yoga_studio" + ], + "Housing": [ + "apartment_building", + "apartment_complex", + "condominium_complex", + "housing_complex" + ], + "Lodging": [ + "bed_and_breakfast", + "budget_japanese_inn", + "campground", + "camping_cabin", + "cottage", + "extended_stay_hotel", + "farmstay", + "guest_house", + "hostel", + "hotel", + "inn", + "japanese_inn", + "lodging", + "mobile_home_park", + "motel", + "private_guest_room", + "resort_hotel", + "rv_park" + ], + "Natural Features": [ + "beach" + ], + "Places of Worship": [ + "church", + "hindu_temple", + "mosque", + "synagogue" + ], + "Services": [ + "astrologer", + "barber_shop", + "beautician", + "beauty_salon", + "body_art_service", + "catering_service", + "cemetery", + "child_care_agency", + "consultant", + "courier_service", + "electrician", + "florist", + "food_delivery", + "foot_care", + "funeral_home", + "hair_care", + "hair_salon", + "insurance_agency", + "laundry", + "lawyer", + "locksmith", + "makeup_artist", + "moving_company", + "nail_salon", + "painter", + "plumber", + "psychic", + "real_estate_agency", + "roofing_contractor", + "storage", + "summer_camp_organizer", + "tailor", + "telecommunications_service_provider", + "tour_agency", + "tourist_information_center", + "travel_agency", + "veterinary_care" + ], + "Shopping": [ + "asian_grocery_store", + "auto_parts_store", + "bicycle_store", + "book_store", + "butcher_shop", + "cell_phone_store", + "clothing_store", + "convenience_store", + "department_store", + "discount_store", + "electronics_store", + "food_store", + "furniture_store", + "gift_shop", + "grocery_store", + "hardware_store", + "home_improvement_store", + "jewelry_store", + "market", + "pet_store", + "shoe_store", + "shopping_mall", + "sporting_goods_store", + "store", + "supermarket", + "warehouse_store", + "wholesaler" + ], + "Sports": [ + "arena", + "athletic_field", + "fishing_charter", + "fishing_pond", + "fitness_center", + "golf_course", + "gym", + "ice_skating_rink", + "playground", + "ski_resort", + "sports_activity_location", + "sports_club", + "sports_coaching", + "sports_complex", + "stadium", + "swimming_pool" + ], + "Transportation": [ + "airport", + "airstrip", + "bus_station", + "bus_stop", + "ferry_terminal", + "heliport", + "international_airport", + "light_rail_station", + "park_and_ride", + "subway_station", + "taxi_stand", + "train_station", + "transit_depot", + "transit_station", + "truck_stop" + ] + } + + # Kinds of fields: + FIELD_MASK_BASIC_INFO = [ + "nextPageToken", + "places.id", + "places.name", + "places.displayName", + "places.formattedAddress", + "places.location", + "places.googleMapsUri", + "places.photos", + "places.primaryType", + "places.types" + ] + FIELD_MASK_BUSINESS_LEADS = [ + "nextPageToken", + "places.id", + "places.name", + "places.displayName", + "places.businessStatus", + "places.formattedAddress", + "places.postalAddress", + "places.location" + "places.googleMapsUri", + "places.photos", + "places.primaryType", + "places.types", + "places.regularOpeningHours", + "places.regularSecondaryOpeningHours", + "places.rating", + "places.userRatingCount", + "places.websiteUri", + "places.internationalPhoneNumber", + "places.priceLevel", + "places.priceRange" + ] + + # ┳┳ ┏┓ ┏•┓ + # ┃┃┏┏┓┏┓ ┃┃┏┓┏┓╋┓┃┏┓ + # ┗┛┛┗ ┛ ┣┛┛ ┗┛┛┗┗┗ + + async def get_user_profile( + self, + tokens: GoogleAuthTokens, + user_id: str = "me", + ) -> GoogleApiResponse: + + """ + To get the details of the user that has logged in. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users/getProfile + 2. https://developers.google.com/people/api/rest/v1/people/get + 3. https://developers.google.com/people/api/rest/v1/people#Person + :param tokens: The object that holds the access token to the service. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client=self._http_client, + client_id=self._client_id, + client_secret=self._client_secret, + force_refresh=False + ) + + # Make the GMail API call: + if not self._debug_only_errors: self._printer("Getting User Profile.") + gmail_api_response = await self.get( + url=f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/profile", + headers={"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if gmail_api_response.httpCode in [200]: + gmail_api_response.success = True + gmail_api_response.data = await gmail_api_response.get_json() + gmail_api_response.data["displayName"] = None + gmail_api_response.data["displayPictureUrl"] = None + + # If the call failed: + else: + self._printer( + gmail_api_response.action, + gmail_api_response.method, + gmail_api_response.httpCode, + ) + return gmail_api_response + + # Make the People API call: + if not self._debug_only_errors: self._printer("Getting User Profile.") + people_api_response = await self.get( + url=f"https://people.googleapis.com/v1/people/me?personFields=names,photos,birthdays,phoneNumbers,genders,emailAddresses,addresses", + headers={"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if people_api_response.httpCode in [200]: + people_api_response.success = True + people_api_response.data = await people_api_response.get_json() + for item in people_api_response.data.get("names", []): + if item["metadata"]["primary"]: + gmail_api_response.data["displayName"] = item.get("displayName") + for item in people_api_response.data.get("photos", []): + if item["metadata"]["primary"]: + gmail_api_response.data["displayPictureUrl"] = item.get("url") + + # If the call failed: + else: + self._printer( + people_api_response.action, + people_api_response.method, + people_api_response.httpCode, + ) + + # Done here: + return gmail_api_response + + # ┏┓ • + # ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏ + # ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛ + # ┛ + + @property + def primary_types(self): + return list(self.PLACE_TYPES.keys()) + + def types_for(self, primary_type: str): + return self.PLACE_TYPES.get(primary_type) + + # async def _nearby_radius_search( + # self, + # tokens: GoogleAuthTokens, + # latitude: float, + # longitude: float, + # radius: float, + # max_count: int = 100, + # next_page_token: str = None, + # ) -> GoogleApiResponse: + + async def nearby_radius_search( + self, + tokens: GoogleAuthTokens, + latitude: float, + longitude: float, + radius: float, + included_primary_types: List[str] = None, + included_types: List[str] = None, + excluded_primary_types: List[str] = None, + excluded_types: List[str] = None, + field_mask: List[str] = None, + max_count: int = 100, + next_page_token: str = None, + ) -> GoogleApiResponse: + + """ + https://developers.google.com/maps/documentation/places/web-service/nearby-search + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Create the headers: + request_headers = {"Authorization": f"Bearer {tokens.accessToken}"} + if field_mask: request_headers["X-Goog-FieldMask"] = ",".join(field_mask) + + # Start creating the JSON payload based on the inputs: + request_json = { + "maxResultCount": max_count, + "locationRestriction": { + "circle": { + "center": { + "latitude": latitude, + "longitude": longitude + }, + "radius": radius + } + } + } + if included_primary_types: request_json["includedPrimaryTypes"] = included_primary_types + if included_types: request_json["includedTypes"] = included_types + if excluded_primary_types: request_json["excludedPrimaryTypes"] = excluded_primary_types + if excluded_types: request_json["excludedTypes"] = excluded_types + + # Make the API call: + if not self._debug_only_errors: self._printer("Listing Nearby (Radius) Places.") + api_response = await self.post( + url = f"https://places.googleapis.com/v1/places:searchNearby", + headers = request_headers, + json = request_json + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + # Create an HTTP client: + test_client = httpx.AsyncClient( + limits = httpx.Limits( + max_connections = 100, # ............ Maximum number of connections allowed in the pool. + max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. + ), + timeout = httpx.Timeout( + connect = 2.5, # ... Shorter connection timeout. + read = 2.5, # ...... Like what EasyEcom gives. + write = 10.0, # .... Time to wait for sending data. + pool = 120.0 # ..... Time to wait for a free connection from the pool. + ) + ) + + # Read the secrets that give you access to the app: + # secrets_file = r"C:\Users\Khushal P Soonderji\Downloads\google_places_test_secret.json" + secrets_file = r"/home/python-dev-debug/Downloads/client_secret_349360248417-uuba8eudk75jg1jag212g5obhc1uostk.apps.googleusercontent.com.json" + secrets_dict = json.from_file(secrets_file) + + async def main(): + + # Create an instance of the client: + my_goog = AsyncPlacesClient( + service_name = "places", + oauth_json = secrets_dict, + http_client = test_client, + redirect_url = r"https://wtt.ditscentre.in/shopify/test/1", + debug = True, + debug_prefix = "Places (M) | ", + debug_only_errors = False + ) + + # Request Auth: + print("AUTH URL:", await my_goog.get_authorization_url( + scopes = SCOPES_PLACES_FULL, + state = "Bhopli", + approval_prompt = "force" + )) + + # Get tokens from callback: + test_tokens = await my_goog.get_authorization_tokens( + scopes = SCOPES_PLACES_FULL, + redirect_url = input("Paste the redirect URL here: ") + ) + print("TOKENS:", test_tokens) + + # List out the primary and secondary types: + print("PRIMARY TYPES:", my_goog.primary_types) + print("'Food and Drink' TYPES:", my_goog.types_for("Food and Drink")) + print("'Alien Spaceship' TYPES:", my_goog.types_for("Alien Spaceship")) + + response = await my_goog.get_user_profile( + tokens=test_tokens + ) + + print("SUCCESS:", response.success) + print("SUMMARY:", response.to_markdown()) + print("\n\n---\n\n") + print("DATA:", json.to_string(response.data, default=str)) + if not response.success: + print("\n\n---\n\n") + + # Test some feature: + response = await my_goog.nearby_radius_search( + tokens = test_tokens, + latitude = 19.03145183334671, + longitude = 72.85437426861148, + radius = 1_000.0, + field_mask = None, + max_count = 5, + ) + print("SUCCESS:", response.success) + print("SUMMARY:", response.to_markdown()) + print("\n\n---\n\n") + print("DATA:", json.to_string(response.data, default = str)) + if not response.success: + print("\n\n---\n\n") + + + asyncio.run(main()) diff --git a/views_v2/software/__init__.py b/views_v2/software/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/views_v2/software/places/__init__.py b/views_v2/software/places/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/views_v2/software/places/oauth/__init__.py b/views_v2/software/places/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/views_v2/software/places/oauth/oauth_cancelled_v2.html b/views_v2/software/places/oauth/oauth_cancelled_v2.html new file mode 100644 index 0000000..0bff500 --- /dev/null +++ b/views_v2/software/places/oauth/oauth_cancelled_v2.html @@ -0,0 +1,114 @@ + + + + + + Authorization Cancelled + + + + + + +
+
!
+

Authorization Cancelled

+

It seems that the authorization for your {{ client }} account was cancelled unexpectedly. + Please feel free to try again whenever you feel like it. You can close this tab at any time.

+ + + +
+ + + + + diff --git a/views_v2/software/places/oauth/oauth_failure_v2.html b/views_v2/software/places/oauth/oauth_failure_v2.html new file mode 100644 index 0000000..e396f42 --- /dev/null +++ b/views_v2/software/places/oauth/oauth_failure_v2.html @@ -0,0 +1,115 @@ + + + + + + Authorization Failed + + + + + + +
+
+

Authorization Failed

+

Something went wrong in getting authorization from your {{ client }} account. +

Hint: {{ failure_hint|safe }}

+ Please feel free to try the same steps again. You can close this tab at any time.

+ + + +
+ + + + + diff --git a/views_v2/software/places/oauth/oauth_success_v2.html b/views_v2/software/places/oauth/oauth_success_v2.html new file mode 100644 index 0000000..33ee0b1 --- /dev/null +++ b/views_v2/software/places/oauth/oauth_success_v2.html @@ -0,0 +1,113 @@ + + + + + + Authorization Successful + + + + + + +
+
+

Authorization Successful

+

We have received authorization from your {{ client }} account. You can close this tab at any time.

+ + + +
+ + + + +