From 55945ad8330c0eb562dcd0223b3a2289edcd25f3 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 25 Jun 2025 13:15:36 +0530 Subject: [PATCH 01/19] initial commit for notify core changes --- Dockerfile | 42 +++-- Pipfile | 11 +- app/constants/__init__.py | 1 + app/constants/constants.py | 31 +++- app/constants/email.py | 2 + app/constants/event.py | 19 ++ app/constants/notification_channels.py | 13 +- app/constants/providers.py | 61 ++++++- app/constants/push.py | 11 +- app/constants/status.py | 13 ++ app/constants/whatsapp.py | 7 +- app/listeners/listener.py | 32 +++- app/manager/base_manager.py | 21 ++- app/manager/email_manager.py | 109 ++++++++++- app/manager/event_manager.py | 58 +++++- app/manager/push_notification_manager.py | 16 +- app/manager/sms_manager.py | 22 ++- app/manager/whatsapp_manager.py | 2 +- app/middlewares/response_middleware.py | 13 +- app/models/notification_core/event.py | 18 ++ .../notification_core/push_notification.py | 1 + .../notification_core/whatsapp_content.py | 3 +- .../notification_request_attempt.py | 7 +- .../notification_request_log.py | 28 ++- .../notification_core_db/push_notification.py | 1 + .../notification_core_db/whatsapp_content.py | 1 + app/repositories/apps.py | 12 ++ app/repositories/base.py | 60 +++++++ app/repositories/event.py | 6 +- .../notification_request_attempt.py | 10 +- app/repositories/notification_request_log.py | 42 +++-- app/routes/__init__.py | 40 ++--- app/routes/apps.py | 11 ++ app/routes/dashboard/__init__.py | 0 app/routes/dashboard/providers.py | 2 + app/routes/email.py | 49 ++++- app/routes/event.py | 72 +++++++- app/routes/form/structure.py | 11 +- app/routes/middleware/authentication.py | 47 +++++ app/routes/push.py | 13 +- app/routes/sms.py | 13 +- app/routes/whatsapp.py | 12 +- app/service.py | 4 - app/service_clients/__init__.py | 4 + app/service_clients/auth/__init__.py | 2 + app/service_clients/auth/auth.py | 36 ++++ app/service_clients/aws_lambda/__init__.py | 2 + app/service_clients/aws_lambda/aws_lambda.py | 19 ++ app/service_clients/base_api_client.py | 37 ++++ app/services/apps.py | 27 +++ app/services/email.py | 40 +++-- app/services/event.py | 169 +++++++++++++++++- app/services/form/enums/field_type.py | 6 + app/services/form/fields/__init__.py | 3 +- app/services/form/fields/base.py | 6 + app/services/form/fields/custom_field.py | 25 +++ app/services/form/fields/input.py | 1 + app/services/form/forms/__init__.py | 1 + app/services/form/forms/create_app/parent.py | 4 +- .../form/forms/create_event/parent.py | 10 +- app/services/form/forms/create_event/push.py | 9 +- .../form/forms/update_event/__init__.py | 1 + .../forms/update_event/default_content.py | 47 +++++ app/services/form/forms/update_event/email.py | 53 ++++++ .../form/forms/update_event/parent.py | 116 ++++++++++++ app/services/form/forms/update_event/push.py | 102 +++++++++++ app/services/form/forms/update_event/sms.py | 67 +++++++ .../form/forms/update_event/whatsapp.py | 52 ++++++ app/services/logging.py | 29 ++- app/services/notification_request.py | 21 ++- app/services/push.py | 86 +++++++-- app/services/sms.py | 20 ++- app/services/status_updates.py | 25 ++- app/services/webhooks/__init__.py | 1 + app/services/webhooks/controller.py | 142 +++++++++++++++ app/services/whatsapp.py | 116 ++++++++++-- app/utilities/aws/s3.py | 2 + .../templates/includes/footer_labs.jade | 27 +-- .../drivers/templates/includes/styles.css | 91 ++++++++++ .../drivers/templates/labs/booking_done.jade | 1 - .../drivers/templates/labs/booking_stage.jade | 2 - .../drivers/templates/labs/regards.jade | 3 - app/utilities/http/rest.py | 8 +- app/utilities/parser.py | 29 ++- app/utilities/pubsub/sqs/sqs_wrapper.py | 2 +- app/utilities/response.py | 31 ++++ app/utilities/url_shortener.py | 49 +++++ app/utilities/utils.py | 21 +++ config_template.json | 22 ++- ...41106120300_set_default_callback_events.py | 15 ++ ...120400_add_variable_mapping_to_whatsapp.py | 12 ++ .../12_20241106120500_add_content_length.py | 13 ++ ...te_notification_request_log_status_enum.py | 17 ++ ...1106120000_add_notification_source_enum.py | 19 ++ ...0_add_source_and_channel_status_columns.py | 24 +++ ...6120200_add_push_notification_type_enum.py | 22 +++ 96 files changed, 2395 insertions(+), 241 deletions(-) create mode 100644 app/constants/event.py create mode 100644 app/constants/status.py create mode 100644 app/repositories/base.py delete mode 100644 app/routes/dashboard/__init__.py create mode 100644 app/routes/middleware/authentication.py create mode 100644 app/service_clients/auth/__init__.py create mode 100644 app/service_clients/auth/auth.py create mode 100644 app/service_clients/aws_lambda/__init__.py create mode 100644 app/service_clients/aws_lambda/aws_lambda.py create mode 100644 app/service_clients/base_api_client.py create mode 100644 app/services/form/fields/custom_field.py create mode 100644 app/services/form/forms/update_event/__init__.py create mode 100644 app/services/form/forms/update_event/default_content.py create mode 100644 app/services/form/forms/update_event/email.py create mode 100644 app/services/form/forms/update_event/parent.py create mode 100644 app/services/form/forms/update_event/push.py create mode 100644 app/services/form/forms/update_event/sms.py create mode 100644 app/services/form/forms/update_event/whatsapp.py create mode 100644 app/services/webhooks/__init__.py create mode 100644 app/services/webhooks/controller.py create mode 100644 app/utilities/response.py create mode 100644 app/utilities/url_shortener.py create mode 100644 migrations/notification_core/10_20241106120300_set_default_callback_events.py create mode 100644 migrations/notification_core/11_20241106120400_add_variable_mapping_to_whatsapp.py create mode 100644 migrations/notification_core/12_20241106120500_add_content_length.py create mode 100644 migrations/notification_core/6_20241106115900_create_notification_request_log_status_enum.py create mode 100644 migrations/notification_core/7_20241106120000_add_notification_source_enum.py create mode 100644 migrations/notification_core/8_20241106120100_add_source_and_channel_status_columns.py create mode 100644 migrations/notification_core/9_20241106120200_add_push_notification_type_enum.py diff --git a/Dockerfile b/Dockerfile index 7561a01..b47414a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,16 @@ ENV PYTHONDONTWRITEBYTECODE=1 # Turns off buffering for easier container logging ENV PYTHONUNBUFFERED=1 +# AWS specific env variables +### Set AWS_METADATA_SERVICE_TIMEOUT - set the timeout to 3 seconds +ENV AWS_METADATA_SERVICE_TIMEOUT=3 +### set AWS_METADATA_SERVICE_NUM_ATTEMPTS - set the max attempts to 3 +ENV AWS_METADATA_SERVICE_NUM_ATTEMPTS=3 + # Args passed in the build command ARG SERVICE_NAME +# Install system dependencies RUN apt-get update && \ apt-get install -y \ git \ @@ -21,29 +28,42 @@ RUN apt-get update && \ libssl-dev \ libffi-dev \ python3-dev \ - pkg-config - -RUN echo "Y" | apt-get install procps + pkg-config \ + procps -RUN pip install --user pipenv -RUN pip install --upgrade pip +# Upgrade pip and install Python tools first +RUN pip install --upgrade pip setuptools wheel # Install Rust RUN curl https://sh.rustup.rs -sSf | bash -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" -# Create home ubuntu service -RUN mkdir -p /home/ubuntu/apps/$SERVICE_NAME/logs +# Install pipenv after setuptools update +RUN pip install pipenv -# switch to code folder +# Set up SSH for private repositories +RUN mkdir -p /root/.ssh +COPY .ssh/id_rsa /root/.ssh/id_rsa +COPY .ssh/known_hosts /root/.ssh/known_hosts +RUN chmod 600 /root/.ssh/id_rsa +RUN chmod 644 /root/.ssh/known_hosts + +# Add Bitbucket to known hosts and configure Git +RUN ssh-keyscan -t rsa bitbucket.org >> /root/.ssh/known_hosts +RUN git config --global core.sshCommand "ssh -o UserKnownHostsFile=/root/.ssh/known_hosts -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa" + +# Create app directory +RUN mkdir -p /home/ubuntu/apps/$SERVICE_NAME/logs WORKDIR /home/ubuntu/apps/$SERVICE_NAME -# Copy and install requirements +RUN pip install aerich==0.7.2 premailer==3.10.0 pyjade==4.0.0 Jinja2==2.10.1 MarkupSafe==0.23 \ + sanic==22.12.0 sanic-openapi==21.12.0 +# Copy requirements files COPY Pipfile Pipfile.lock /home/ubuntu/apps/$SERVICE_NAME/ -RUN /root/.local/bin/pipenv sync --system +RUN pipenv sync --system # Copy code folder COPY . . -#Start the service +# Start the service CMD ["python3", "-m", "app.service"] \ No newline at end of file diff --git a/Pipfile b/Pipfile index 78e7d89..2cd03d8 100644 --- a/Pipfile +++ b/Pipfile @@ -4,16 +4,19 @@ verify_ssl = true name = "pypi" [packages] -torpedo = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/torpedo.git"} -commonutils = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/commonutils.git"} -tortoise_wrapper = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/tortoise_wrapper.git"} -cache_wrapper = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/cache_wrapper.git"} +torpedo = {editable = true, ref = "3.10.1", git = "ssh://git@bitbucket.org/tata1mg/torpedo.git"} +commonutils = {editable = true, ref = "1.8.3", git = "ssh://git@bitbucket.org/tata1mg/commonutils.git"} +tortoise_wrapper = {editable = true, ref = "0.7.0", git = "ssh://git@bitbucket.org/tata1mg/tortoise_wrapper.git"} +cache_wrapper = {editable = true, ref = "4.0.1", git = "ssh://git@bitbucket.org/tata1mg/cache_wrapper.git"} aerich = "0.7.2" premailer = "===3.10.0" pyjade = "===4.0.0" Jinja2 = "===2.10.1" MarkupSafe = "===0.23" pycrypto = "===2.6.1" +sanic = "==22.12.0" +sanic-ext = "23.12.0" +sanic-openapi = "21.12.0" [dev-packages] diff --git a/app/constants/__init__.py b/app/constants/__init__.py index aafbc19..59807a3 100644 --- a/app/constants/__init__.py +++ b/app/constants/__init__.py @@ -5,6 +5,7 @@ from .database_tables import DatabaseTables from .notification_request_log_status import NotificationRequestLogStatus from .email import Email +from .whatsapp import VariableMappingKeys from .providers import Providers, ProvidersStatus from .providers_default_priority import ProvidersDefaultPriorityStatus from .providers_dynamic_priority import ProvidersDynamicPriorityStatus diff --git a/app/constants/constants.py b/app/constants/constants.py index f54ff3e..67c6684 100644 --- a/app/constants/constants.py +++ b/app/constants/constants.py @@ -1,7 +1,11 @@ from enum import Enum +from commonutils.utils import CustomEnum +URL_SHORTENER_START_PATTERN = "__URL_SHORTNER_START__" +URL_SHORTENER_END_PATTERN = "__URL_SHORTNER_END__" MAX_DEVICES_FOR_PUSH = 10 +TRUNCATE_MAX_LEN = 5000 class Action(Enum): @@ -22,8 +26,11 @@ class Event: "priority", "event_type", ] + TRIGGER_LIMIT = 'trigger_limit' ADD_ACTION_REQUIRED_PARAMS = ["app_name", "event_name", "user_email"] + EVENT_UPDATE_REQUIRED_PARAMS = ['app_name', 'event_name', 'event_type', 'priority', 'callback_enabled', 'payload'] EVENT_PRIORITY = "priority" + DYNAMIC_CHANNELS = "dynamic_channels" EVENT_TYPE = "event_type" NEW_EVENT_CREATED_MESSAGE = "New event created with event_name = {event_name},app_name = {app_name} by {user_email}." NEW_ACTION_ADDED_MESSAGE = "New action added in event_name = {event_name},app_name = {app_name} by {user_email}." @@ -37,6 +44,7 @@ class Event: SOFT_DELETE_DEFAULT_VALUE = False DEFAULT_LIMIT = 1000 DEFAULT_OFFSET = 0 + ID = "id" class EventType(Enum): @@ -45,6 +53,27 @@ class EventType(Enum): OTHER = "other" +class EventPriority(CustomEnum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @classmethod + def _missing_(cls, _value): + return cls.LOW + + class SyncDispatcher: - ENDPOINT = "/notify" + ENDPOINT = "/v4/notify" METHOD = "POST" + + +class Redis: + REDIS_NAMESPACE = 'notification_core_redis' + REDIS_EXPIRY_TIME_MED = 60 * 60 + KEY_DELIMITER = '_' + + +class RedisKeyPrefixes: + EVENT_BODY_LATEST_VERSION = 'event_body_latest_version' diff --git a/app/constants/email.py b/app/constants/email.py index 561530e..a6b5534 100644 --- a/app/constants/email.py +++ b/app/constants/email.py @@ -1,9 +1,11 @@ class Email: + REPLY_TO = 'no-reply@mail.1mg.com' INCLUDE_START = '$include_start-' INCLUDE_END = '-$include_end' SUBJECT = 'subject' DESCRIPTION = 'description' CONTENT = 'content' + ID = 'id' NAME = 'name' CONTENT_COLUMNS=['id', 'event_id', 'name', 'description', 'subject', 'content','updated_by'] REDIS_EXPIRY_TIME_LONG = 24 * 60 * 60 diff --git a/app/constants/event.py b/app/constants/event.py new file mode 100644 index 0000000..14928e0 --- /dev/null +++ b/app/constants/event.py @@ -0,0 +1,19 @@ +from enum import Enum +from commonutils.utils import CustomEnum + + +class EventPriority(CustomEnum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @classmethod + def _missing_(cls, _value): + return cls.LOW + + +class EventType(Enum): + PROMOTIONAL = "promotional" + TRANSACTIONAL = "transactional" + OTP = "otp" diff --git a/app/constants/notification_channels.py b/app/constants/notification_channels.py index a62b8fb..bd0c041 100644 --- a/app/constants/notification_channels.py +++ b/app/constants/notification_channels.py @@ -25,5 +25,14 @@ def get_sent_to_for_channel(cls, channel, request_body) -> list: return send_address @classmethod - def get_source_identifier_for_event(cls, request_body): - return request_body["source_identifier"] + def get_source_identifier_for_event(cls, event_name, request_body): + # TODO add more logic for other apps of needed + source_identifier = None + if 'body' in request_body: + body = request_body['body'] or dict() + if 'order' in body: + order = body['order'] or dict() + if 'order_id' in order or 'appointment_id' in order: + source_identifier = order.get('order_id') or order.get('appointment_id') + return source_identifier + diff --git a/app/constants/providers.py b/app/constants/providers.py index 361ed99..bc51dc7 100644 --- a/app/constants/providers.py +++ b/app/constants/providers.py @@ -63,10 +63,11 @@ class Providers(CustomEnum): NotificationChannels.SMS.value ], "configuration": { + "PLIVO_SMS_URL": "", "PLIVO_AUTH_ID": "", "PLIVO_AUTH_TOKEN": "", "PLIVO_SENDER_ID": "", - "PLIVO_CALLBACK_URL": "" + "PLIVO_CALLBACK_URL": "", } } @@ -81,7 +82,13 @@ class Providers(CustomEnum): "SMS_COUNTRY_USERNAME": "", "SMS_COUNTRY_PASSWORD": "", "SMS_COUNTRY_SENDER_ID": "", - "SMS_COUNTRY_URL": "" + "SMS_COUNTRY_OTP_USERNAME": "", + "SMS_COUNTRY_OTP_PASSWORD": "", + "SMS_COUNTRY_OTP_SENDER_ID": "", + "SMS_COUNTRY_DROPLET_USERNAME": "", + "SMS_COUNTRY_DROPLET_PASSWORD": "", + "SMS_COUNTRY_DROPLET_SENDER_ID": "", + "SMS_COUNTRY_URL": "", } } @@ -93,7 +100,36 @@ class Providers(CustomEnum): NotificationChannels.PUSH.value ], "configuration": { - "AUTH_KEY": "" + "PROJECT_ID": "", + "TYPE": "", + "PRIVATE_KEY_ID": "", + "PRIVATE_KEY": "", + "CLIENT_EMAIL": "", + "CLIENT_ID": "", + "TOKEN_URI": "", + "AUTH_URI": "", + "AUTH_PROVIDER_X509_CERT_URL": "", + "CLIENT_X509_CERT_URL": "", + "UNIVERSE_DOMAIN": "" + } + } + + APNS = { + "name": "Apple Push Notification Service", + "code": "APNS", + "logo": "https://raw.githubusercontent.com/tata1mg/notifyone-core/refs/heads/notifyone/issues/14/media/logo/apns.png", + "channels": [ + NotificationChannels.PUSH.value + ], + "configuration": { + "HOST": "", + "ENDPOINT": "", + "BUNDLE_IDENTIFIER": "", + "REFRESH_TOKEN_DELAY": 1800, + "ALGORITHM": "", + "TEAM_ID": "", + "KEY_ID": "", + "PRIVATE_KEY": "" } } @@ -105,8 +141,23 @@ class Providers(CustomEnum): NotificationChannels.WHATSAPP.value ], "configuration": { - "CAPACITY": 0, - "AUTHORIZATION_TOKEN": "" + "APP_AUTHORIZATIONS": { + "corporate-service": "CORPORATE", + "diagnostics": "DIAGNOSTICS", + "health_records": "DIAGNOSTICS", + "off": "PHARMACY", + "ppmc_api": "CORPORATE", + "validation_service": "PHARMACY", + "verification": "PHARMACY" + }, + "AUTHORIZATION": { + "CORPORATE": "Basic VkN6SjkxZVQ3SkV6R2sxdmtMblZkb092M1dHUVR2RXZ2ekVUVlJBNHZsTTo=", + "DEFAULT": "Basic dm9VOWNseVNPeUpadUpmQ2VTMnVfdFhqdzB4V3JvWVNvUWozOVpIM2NGNDo=", + "DIAGNOSTICS": "Basic dm9VOWNseVNPeUpadUpmQ2VTMnVfdFhqdzB4V3JvWVNvUWozOVpIM2NGNDo=", + "PHARMACY": "Basic TTl1SFg2TlZxOWJjZDNndldsSGhfRVBWRVd0ZWNRbHVVS2ZOeV8temllZzo=" + }, + "HOST": "https://api.interakt.ai", + "PATH": "/v1/public/message/" } } diff --git a/app/constants/push.py b/app/constants/push.py index 1828b95..f01c8d9 100644 --- a/app/constants/push.py +++ b/app/constants/push.py @@ -1,4 +1,4 @@ -from commonutils.utils import CustomEnum +from enum import Enum class PushTarget: @@ -87,11 +87,16 @@ class PushTarget: } -class DeviceType(CustomEnum): +class DeviceType(Enum): ALL = "ALL" - iOS = "IOS" + iOS = "IPHONE OS" ANDROID = "ANDROID" +class NotificationType(Enum): + BANNER = "BANNER" + CALL = "CALL" + LIVE_ACTIVITY = "LIVE_ACTIVITY" + BACKGROUND = "BACKGROUND" class Push: TITLE = "title" diff --git a/app/constants/status.py b/app/constants/status.py new file mode 100644 index 0000000..80febd1 --- /dev/null +++ b/app/constants/status.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class ExecutionDetailsSource(str, Enum): + INTERNAL = "INTERNAL" + WEBHOOK = "WEBHOOK" + + +class ExecutionDetailsEventStatus(str, Enum): + QUEUED = "QUEUED" + PENDING = "PENDING" + SUCCESS = "SUCCESS" + FAILED = "FAILED" diff --git a/app/constants/whatsapp.py b/app/constants/whatsapp.py index acd7319..8e51323 100644 --- a/app/constants/whatsapp.py +++ b/app/constants/whatsapp.py @@ -1,4 +1,9 @@ +from enum import Enum class Whatsapp: CONTENT_COLUMNS=['id','event_id','name'] NAME = 'name' - \ No newline at end of file + +class VariableMappingKeys(Enum): + BODY = "body" + HEADER = "header" + BUTTON = "button" \ No newline at end of file diff --git a/app/listeners/listener.py b/app/listeners/listener.py index ddd9e8a..f2f0db2 100644 --- a/app/listeners/listener.py +++ b/app/listeners/listener.py @@ -1,6 +1,7 @@ from torpedo import CONFIG from torpedo.constants import ListenerEventTypes - +from redis_wrapper import cache_registry +from asyncio import BaseEventLoop from app.repositories.content_log import ContentLogRepository from app.services import SubscribeNotificationRequest, SubscribeStatusUpdate from app.services.email import EmailHandler @@ -36,12 +37,39 @@ async def setup_repositories(app, loop): config=CONFIG.config.get("CONTENT_LOG", {}).get("S3") ) +async def initialize_redis_caches(app): + cache_config = CONFIG.config["REDIS_CACHE_HOSTS"] + cache_registry.from_config(cache_config) + + +def exc_handler(self: BaseEventLoop, context: dict) -> None: + """ + Custom exception handler to filter out specific messages. + :param self: The event loop instance + :param context: The context dictionary containing exception details + :return: None + """ + message = context.get("message") + if message not in ["Unclosed connector", "Unclosed client session"]: + BaseEventLoop.default_exception_handler(self, context) + + +async def setup_custom_exc_handler(app, loop): + """ + Set up the custom exception handler for the event loop. + :param app: The current app instance + :param loop: The running event loop + :return: None + """ + loop.set_exception_handler(exc_handler) + listeners = [ + (initialize_redis_caches, ListenerEventTypes.BEFORE_SERVER_START.value), (subscribe_for_notification_requests, ListenerEventTypes.AFTER_SERVER_START.value), (subscribe_for_status_updates, ListenerEventTypes.AFTER_SERVER_START.value), (setup_notification_channel_handlers, ListenerEventTypes.AFTER_SERVER_START.value), (initialize_jinja_environment, ListenerEventTypes.AFTER_SERVER_START.value), (setup_repositories, ListenerEventTypes.AFTER_SERVER_START.value), - (setup_options, ListenerEventTypes.BEFORE_SERVER_START.value), + (setup_custom_exc_handler, ListenerEventTypes.BEFORE_SERVER_START.value), ] diff --git a/app/manager/base_manager.py b/app/manager/base_manager.py index 3e00f95..dbb8f29 100644 --- a/app/manager/base_manager.py +++ b/app/manager/base_manager.py @@ -7,22 +7,31 @@ class BaseManager: @classmethod - async def update_trigger_limit(cls, event_type: str, agent_id: str, **kwargs): + async def update_trigger_limit_or_actions(cls, event_type: str, agent_id: str, **kwargs): app_name = kwargs["payload"].get("app_name") event_name = kwargs["payload"].get("event_name") event_id = kwargs["payload"].get("event_id") data = await cls.common_validation_checks(app_name, event_name, event_id) event_limit = kwargs["payload"].get("triggers_limit") + event_action = kwargs["payload"].get("actions") + values = { + "updated_by": agent_id, + } + if event_limit: triggers_limit = data.triggers_limit # as in some cases trigger_limit is instance of str. triggers_limit[event_type] = int(event_limit) - updated_trigger_limit = triggers_limit - values = { - "updated_by": agent_id, - "triggers_limit": json.dumps(updated_trigger_limit), - } + values["triggers_limit"] = triggers_limit + + if 'actions' in kwargs["payload"] and event_action is not None: + actions = data.actions + # as in some cases actions is instance of str. + actions[event_type] = int(event_action) + values["actions"] = actions + + if event_limit or 'actions' in kwargs["payload"] and event_action is not None: await EventRepository.update_event(app_name, event_name, values=values) @staticmethod diff --git a/app/manager/email_manager.py b/app/manager/email_manager.py index 90f30d6..b61b556 100644 --- a/app/manager/email_manager.py +++ b/app/manager/email_manager.py @@ -14,6 +14,7 @@ current_utc_timestamp, get_event_unique_identifier, get_email_content_path, + async_gather_dict, ) from app.services.email import EmailHandler from app.utilities.drivers.jinja import CustomJinjaEnvironment @@ -23,6 +24,7 @@ from app.models.notification_core.email_content import EmailContentModel from app.manager.base_manager import BaseManager from app.constants.constants import Event +from app.repositories.event import EventRepository logger = logging.getLogger() @@ -156,6 +158,7 @@ async def update_email_template(cls, id, updated_by, data, **kwargs): if kwargs["payload"].get("name"): to_update.update({"name": kwargs["payload"].get("name")}) + event_id = kwargs["payload"].get("event_id") template_ids_affected = await CacheHelper.get_preview_cache( user_id=updated_by, template_id=id ) @@ -163,18 +166,19 @@ async def update_email_template(cls, id, updated_by, data, **kwargs): raise NotFoundException( "Run Preview Again" ) - await cls.update_trigger_limit( + + await cls.update_template_db_files(template_ids_affected) + await cls.update_trigger_limit_or_actions( NotificationChannels.EMAIL.value, updated_by, **kwargs ) to_update["updated"] = current_utc_timestamp() await EmailContentRepository.update_email_content( template_id=id, to_update=to_update ) - event_id = kwargs["payload"].get("event_id") - await GenericDataStoreManager.update_or_insert_data_store_entry( - event_id, data, updated_by - ) - await cls.update_template_db_files(template_ids_affected) + if not kwargs.get('update_event'): + await GenericDataStoreManager.update_or_insert_data_store_entry( + event_id, data, updated_by + ) await EmailHandler.handle_email_template_update( latest_template_version=current_epoch_in_millis() ) @@ -328,6 +332,99 @@ async def include_email_template( "updated_by": user_email, } return response + + @classmethod + def get_paginated_events(cls, all_events, start, size): + all_events = sorted(all_events) + events = list() + for id in range(start, start + size): + if id <= (len(all_events) - 1): + events.append(all_events[id]) + return events + + @classmethod + async def preview_email_subtemplate(cls, template_id, description, content, user_email, start, size): + if template_id is None: + raise BadRequestException("template id is missing in the payload") + + if content is None: + raise BadRequestException("content is missing in the payload template") + + template_details = await EmailContentRepository.get_email_content_from_template_id(id=template_id) + if not template_details: + raise NotFoundException("template doesn't exist") + + all_non_event_template_details = await EmailHandler.get_all_email_content_map() + result = [] + all_non_event_template_details[int(template_id)] = content + all_events = await EmailHandler.get_all_dependent_events(template_id) + events = cls.get_paginated_events(all_events, start, size) + + if not events: + raise NotFoundException("no email template found") + + tasks = {} + for template_id in events: + template_details = await EmailContentRepository.get_email_content_from_template_id(template_id) + tasks.update({template_id: cls.get_template_content(template_details, all_non_event_template_details, "preview")}) + data = await async_gather_dict(tasks, return_exceptions=True) + result = [{"id": key, "subject": val[1], "content": val[0]} for key, val in data.items()] + final_result = {"previews": result, "start": start, "size": size, "total_count": len(all_events)} + return final_result + + @classmethod + async def get_email_template_by_id(cls, template_id): + if not template_id: + raise RequiredParamsException("template id is missing in query params") + + email_template = await EmailContentRepository.get_email_content_from_template_id(template_id) + if not email_template: + raise NotFoundException("no email template is found") + + event_id = email_template.event_id + trigger_limit = None + if event_id: + event = await EventRepository.get_events_by_id(event_id) + trigger_limits = event.triggers_limit + trigger_limit = trigger_limits.get("email") + + if email_template.content: + includes = await cls.get_includes_in_template(email_template.content, add_template_name=True) + response = { + "id": email_template.id, + "includes": includes, + "subject": email_template.subject, + "content": email_template.content, + "name": email_template.name, + "description": email_template.description, + "event_id": email_template.event_id, + "trigger_limit": trigger_limit + } + return response + + @classmethod + async def update_email_subtemplate(cls, id, content, description, user_email): + if not id: + raise RequiredParamsException( + ErrorMessages.REQUIRED_FIELD.value.format( + NotificationChannels.EMAIL.value, Email.ID + ) + ) + if not content: + raise RequiredParamsException( + ErrorMessages.REQUIRED_FIELD.value.format( + NotificationChannels.EMAIL.value, Email.CONTENT + ) + ) + description = description + to_update = { + "description": description, + "content": content, + "updated_by": user_email, + "updated": current_utc_timestamp() + } + await EmailContentRepository.update_email_content(id, to_update) + return {"message": "Successfully updated"} class CreateEmailEvent: """ diff --git a/app/manager/event_manager.py b/app/manager/event_manager.py index 18e552b..585912c 100644 --- a/app/manager/event_manager.py +++ b/app/manager/event_manager.py @@ -1,12 +1,22 @@ import logging from typing import Optional +import json from app.constants import sms from app.repositories.event import EventRepository from app.manager.email_manager import EmailManager from app.manager.sms_manager import SmsManager from app.manager.push_notification_manager import PushManager from app.manager.whatsapp_manager import WhatsappManager -from app.constants.constants import Event +from app.manager.generic_data_manager import GenericDataStoreManager +from app.constants.constants import Event, Redis, RedisKeyPrefixes +from app.caches import NotificationCoreCache +from app.utilities.utils import current_epoch_in_millis +from app.repositories.email_content import EmailContentRepository +from app.repositories.sms_content import SmsContentRepository +from app.repositories.push_notification import PushNotificationRepository +from app.repositories.whatsapp_content import WhatsappContentRepository +from app.repositories.generic_data_store import GenericDataRepository +from app.utilities.utils import async_gather_dict logger = logging.getLogger() @@ -117,3 +127,49 @@ def filter_all_templates(cls, data): push_templates.append({**template, **push_template}) return email_templates, sms_templates, push_templates, whatsapp_templates + + @classmethod + async def save_event_body(cls, event_id: str, event_body: dict): + """ + This function is used for saving meta-data for showing preview while editing of templates from UI + :param event_id: + :param event_body: + :return: + """ + cache_key = cls._get_event_body_cache_key(event_id) + value = await cls.get_cached_data(cache_key) + if not value: + latest_data_version = str(current_epoch_in_millis()) + await GenericDataStoreManager.update_or_insert_data_store_entry(event_id, event_body) + await NotificationCoreCache.set_key(cache_key, latest_data_version, expire=Redis.REDIS_EXPIRY_TIME_MED) + + @classmethod + def _get_event_body_cache_key(cls, event_id: str): + return Redis.KEY_DELIMITER.join([Redis.REDIS_NAMESPACE, RedisKeyPrefixes.EVENT_BODY_LATEST_VERSION, event_id]) + + @classmethod + async def get_cached_data(cls, key: str): + data = await NotificationCoreCache.get_key(key) + return json.loads(data) if data else None + + +class EventMetaData: + def __init__(self, event_id) -> None: + self._event_id = event_id + async def get_meta_data(self): + tasks = { + "email": EmailContentRepository.get_email_content_from_event_id(self._event_id), + "sms": SmsContentRepository.get_sms_content_from_event_id(self._event_id), + "push": PushNotificationRepository.get_push_notification(self._event_id), + "whatsapp": WhatsappContentRepository.get_whatsapp_content_from_event_id(self._event_id), + "generic_data": GenericDataRepository.get_data_store_entry(self._event_id, category="event_body") + } + result = await async_gather_dict(tasks, return_exceptions=True) + return { + 'event': {'id': self._event_id}, + 'email': {'id': result.get("email").id if result.get("email") else None}, + 'sms': {'id': result.get("sms").id if result.get("sms") else None}, + 'push': {'id': result.get("push").id if result.get("push") else None}, + 'whatsapp': {'id': result.get("whatsapp").id if result.get("whatsapp") else None}, + 'payload': result.get("generic_data").data if result.get("generic_data") else None + } \ No newline at end of file diff --git a/app/manager/push_notification_manager.py b/app/manager/push_notification_manager.py index 1c54cb4..16e9fe6 100644 --- a/app/manager/push_notification_manager.py +++ b/app/manager/push_notification_manager.py @@ -159,17 +159,24 @@ async def update_push_notification(cls, push_id, data, agent_id, **kwargs): if "target" in kwargs["payload"]: to_update["target"] = cls.get_push_target(kwargs["payload"]["target"]) + if kwargs["payload"].get("device_type"): + to_update.update({"device_type": kwargs["payload"].get("device_type")}) + + if kwargs["payload"].get("device_version"): + to_update.update({"device_version": kwargs["payload"].get("device_version")}) + to_update["updated"] = current_utc_timestamp() - await cls.update_trigger_limit( + await cls.update_trigger_limit_or_actions( NotificationChannels.PUSH.value, agent_id, **kwargs ) await PushNotificationRepository.update_push_notification( notification_id=push_id, to_update=to_update ) event_id = kwargs["payload"].get("event_id") - await GenericDataStoreManager.update_or_insert_data_store_entry( - event_id, data, agent_id - ) + if not kwargs.get('update_event'): + await GenericDataStoreManager.update_or_insert_data_store_entry( + event_id, data, agent_id + ) @staticmethod async def get_push_template_previews(event_id, body, title, data): @@ -232,6 +239,7 @@ async def get_data_for_event( "body": push_body, "target": data.get("target", ""), "image": data.get("image", ""), + "type": data.get("type"), "device_type": data.get( "device_type", "ALL" ), # NOTE: by default device_type will be ALL diff --git a/app/manager/sms_manager.py b/app/manager/sms_manager.py index 349aed3..ae399ba 100644 --- a/app/manager/sms_manager.py +++ b/app/manager/sms_manager.py @@ -77,23 +77,29 @@ async def _filter_sms_templates(cls, sms_templates: Dict): @classmethod async def update_sms_template(cls, sms_id, data, agent_id, **kwargs): - if kwargs["payload"].get("content") is None: - raise BadRequestException("content is missing in the paylaod") + payload = kwargs.get("payload", {}) + content = payload.get("content") + event_id = payload.get("event_id") + + if content is None: + raise BadRequestException("content is missing in the payload") + to_update = { - "content": kwargs["payload"].get("content"), + "content": content, "updated_by": agent_id, } - await cls.update_trigger_limit( + + await cls.update_trigger_limit_or_actions( NotificationChannels.SMS.value, agent_id, **kwargs ) to_update["updated"] = current_utc_timestamp() await SmsContentRepository.update_sms_template( sms_id=sms_id, to_update=to_update ) - event_id = kwargs["payload"].get("event_id") - await GenericDataStoreManager.update_or_insert_data_store_entry( - event_id, data, agent_id - ) + if not kwargs.get('update_event'): + await GenericDataStoreManager.update_or_insert_data_store_entry( + event_id, data, agent_id + ) @staticmethod async def get_sms_template_previews(content, event_name, user_email, data): diff --git a/app/manager/whatsapp_manager.py b/app/manager/whatsapp_manager.py index c86919a..2cab7a6 100644 --- a/app/manager/whatsapp_manager.py +++ b/app/manager/whatsapp_manager.py @@ -69,7 +69,7 @@ async def update_whatsapp_table(cls, whatsapp_template_id, agent_id, **kwargs): raise BadRequestException("name is missing in the payload") to_update = {"name": kwargs["payload"].get("name"), "updated_by": agent_id} - await cls.update_trigger_limit( + await cls.update_trigger_limit_or_actions( NotificationChannels.WHATSAPP.value, agent_id, **kwargs ) to_update["updated"] = current_utc_timestamp() diff --git a/app/middlewares/response_middleware.py b/app/middlewares/response_middleware.py index d65e7f6..d896702 100644 --- a/app/middlewares/response_middleware.py +++ b/app/middlewares/response_middleware.py @@ -19,6 +19,17 @@ def _add_cors_headers(response, methods: Iterable[str]) -> None: async def add_cors_headers(request, response): - if request.method != "OPTIONS": + print(f"Request incoming: {request.method} {request.path} {request.route}") + if request.method == "OPTIONS": + # For OPTIONS requests, we need to add CORS headers + # We'll use a default set of methods since there's no route.methods for OPTIONS + methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] + _add_cors_headers(response, methods) + elif hasattr(request, 'route') and request.route is not None: + # For non-OPTIONS requests with valid routes methods = [method for method in request.route.methods] _add_cors_headers(response, methods) + else: + # For error cases where route might be None + methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] + _add_cors_headers(response, methods) \ No newline at end of file diff --git a/app/models/notification_core/event.py b/app/models/notification_core/event.py index bd4776c..842c9bc 100644 --- a/app/models/notification_core/event.py +++ b/app/models/notification_core/event.py @@ -12,6 +12,7 @@ def __init__(self, event_dict: dict): self.event_type = event_dict['event_type'] self.meta_info = event_dict['meta_info'] self.is_deleted = event_dict.get('is_deleted', False) + self.callback_enabled = event_dict.get('callback_enabled', False) self.updated_by = event_dict['updated_by'] self.created = event_dict['created'] self.updated = event_dict['updated'] @@ -52,6 +53,23 @@ def is_trigger_limit_enabled_for_channel(self, channel) -> bool: if channel in self.triggers_limit and self.triggers_limit[channel] != -1: return True return False + + @property + def dynamic_channel_allowed(self): + return self.meta_info.get("dynamic_channels", False) + + def get_allowed_channels(self, allowed_channels): + """ + if the event supports dynamic channels, + then return the allowed channels from the request body + else return all the active channels + """ + all_channels = self.get_active_channels() + if self.dynamic_channel_allowed: + if allowed_channels: + return list(set(allowed_channels).intersection(all_channels)) + return all_channels + @classmethod def attributes_available_to_fetch(cls): diff --git a/app/models/notification_core/push_notification.py b/app/models/notification_core/push_notification.py index aa9a41e..470d41d 100644 --- a/app/models/notification_core/push_notification.py +++ b/app/models/notification_core/push_notification.py @@ -12,3 +12,4 @@ def __init__(self, push_notification_dict: dict): self.updated_by = push_notification_dict['updated_by'] self.created = push_notification_dict['created'] self.updated = push_notification_dict['updated'] + self.type = push_notification_dict['type'] diff --git a/app/models/notification_core/whatsapp_content.py b/app/models/notification_core/whatsapp_content.py index d031ca5..b3f6ebf 100644 --- a/app/models/notification_core/whatsapp_content.py +++ b/app/models/notification_core/whatsapp_content.py @@ -4,4 +4,5 @@ def __init__(self, whatsapp_content_dict: dict): self.id = whatsapp_content_dict['id'] self.event_id = whatsapp_content_dict['event_id'] self.name = whatsapp_content_dict['name'] - self.updated_by = whatsapp_content_dict['updated_by'] \ No newline at end of file + self.updated_by = whatsapp_content_dict['updated_by'] + self.variable_mapping = whatsapp_content_dict['variable_mapping'] \ No newline at end of file diff --git a/app/models/notification_core_db/notification_request_attempt.py b/app/models/notification_core_db/notification_request_attempt.py index cdc0df8..7e1a4ce 100644 --- a/app/models/notification_core_db/notification_request_attempt.py +++ b/app/models/notification_core_db/notification_request_attempt.py @@ -8,19 +8,20 @@ class NotificationRequestAttemptDBModel(BaseModel): class Meta: table = DatabaseTables.NOTIFICATION_REQUEST_ATTEMPT.value - indexes = (("operator_event_id",), ("created",),) id: int = fields.BigIntField(pk=True) log_id: int = fields.BigIntField() channel = fields.CharField(max_length=100) - sent_to = fields.CharField(max_length=5000, null=True) + sent_to = fields.CharField(max_length=10000, null=True) status = fields.CharField( max_length=50, default=NotificationRequestLogStatus.NEW.value ) operator = fields.CharField(max_length=50, null=True) - operator_event_id = fields.CharField(max_length=200, null=True) + operator_event_id = fields.CharField(max_length=1000, null=True) message = fields.CharField(max_length=5000, null=True) metadata = fields.CharField(max_length=5000, null=True) + source = fields.CharField(max_length=50, default="UNKNOWN") + channel_status = fields.CharField(max_length=50, default="UNKNOWN") attempt_number = fields.IntField() sent_at = NaiveDatetimeField(auto_now=True) created = NaiveDatetimeField(auto_now=True) diff --git a/app/models/notification_core_db/notification_request_log.py b/app/models/notification_core_db/notification_request_log.py index 290b67c..9b44201 100644 --- a/app/models/notification_core_db/notification_request_log.py +++ b/app/models/notification_core_db/notification_request_log.py @@ -12,26 +12,20 @@ class NotificationRequestLogDBModel(BaseModel): class Meta: table = DatabaseTables.NOTIFICATION_REQUEST_LOG.value - indexes = (("source_identifier", "event_id",),) id: int = fields.BigIntField(pk=True) event_id = fields.BigIntField() - notification_request_id = fields.CharField(max_length=100, index=True) + content_length = fields.BigIntField() + notification_request_id = fields.CharField(max_length=100) channel = fields.CharField(max_length=100) - sent_to = fields.CharField(max_length=1000, null=True, index=True) - source_identifier = fields.CharField(max_length=1000, null=True, index=True) - status = fields.CharEnumField(enum_type=NotificationRequestLogStatus, default=NotificationRequestLogStatus.NEW.value) + sent_to = fields.CharField(max_length=1000, null=True) + source_identifier = fields.CharField(max_length=1000, null=True) + status = fields.CharField(max_length=50, default=NotificationRequestLogStatus.NEW.value) operator = fields.CharField(max_length=50, null=True) - operator_event_id = fields.CharField(max_length=200, null=True, index=True) - message = fields.TextField(null=True) - metadata = fields.TextField(null=True) - created = NaiveDatetimeField(auto_now=True, index=True) + operator_event_id = fields.CharField(max_length=1000, null=True) + source = fields.CharField(max_length=50, default="UNKNOWN") + channel_status = fields.CharField(max_length=50, default="UNKNOWN") + message = fields.CharField(max_length=5000, null=True) + metadata = fields.CharField(max_length=5000, null=True) + created = NaiveDatetimeField(auto_now=True) updated = NaiveDatetimeField(auto_now=True) - - async def to_dict( - self, filter_keys=None, get_related=True, related_fields=None - ): - status = self.status - dict_data = await super(NotificationRequestLogDBModel, self).to_dict() - dict_data['status'] = status.value - return dict_data \ No newline at end of file diff --git a/app/models/notification_core_db/push_notification.py b/app/models/notification_core_db/push_notification.py index 3b24f77..fe236c8 100644 --- a/app/models/notification_core_db/push_notification.py +++ b/app/models/notification_core_db/push_notification.py @@ -16,5 +16,6 @@ class Meta: device_type = fields.CharField(max_length=50) device_version = fields.CharField(max_length=50) updated_by = fields.CharField(max_length=100) + type = fields.CharField(max_length=50, default="BANNER") created = NaiveDatetimeField(auto_now=True) updated = NaiveDatetimeField(auto_now=True) diff --git a/app/models/notification_core_db/whatsapp_content.py b/app/models/notification_core_db/whatsapp_content.py index e6b38ed..2ae4741 100644 --- a/app/models/notification_core_db/whatsapp_content.py +++ b/app/models/notification_core_db/whatsapp_content.py @@ -11,6 +11,7 @@ class Meta: id = fields.BigIntField(pk=True) event_id = fields.BigIntField(unique=True) name = fields.TextField() + variable_mapping = fields.JSONField(default="{}") updated_by = fields.CharField(max_length=100) created = NaiveDatetimeField(auto_now=True) updated = NaiveDatetimeField(auto_now=True) \ No newline at end of file diff --git a/app/repositories/apps.py b/app/repositories/apps.py index d3e6869..f0afed3 100644 --- a/app/repositories/apps.py +++ b/app/repositories/apps.py @@ -82,3 +82,15 @@ async def filter_cols( columns = [] return await AppsDBModel.filter(**filters).values_list(*columns, flat=flat) + + @classmethod + async def delete_app(cls, app_id: int) -> AppsDBModel: + where_clause = {"id": app_id} + apps = await ORMWrapper.get_by_filters(AppsDBModel, where_clause, limit=1) + if not apps: + raise NotFoundException("App does not exist") + app = apps[0] + if app: + #delete the app with the given id + await ORMWrapper.delete_with_filters(app, AppsDBModel) + return app \ No newline at end of file diff --git a/app/repositories/base.py b/app/repositories/base.py new file mode 100644 index 0000000..93b6737 --- /dev/null +++ b/app/repositories/base.py @@ -0,0 +1,60 @@ +from typing import Optional +import logging + +from tortoise_wrapper.wrappers import ORMWrapper + +logger = logging.getLogger() + + +class BaseRepository: + class Queries: + GET_CALLBACK_DETAILS = """ +select + r.notification_request_id, + r.source_identifier, + r.sent_to, + r.operator, + r.operator_event_id, + e.id as event_id, + e.event_name, + e.callback_enabled, + a.name as app_name, + a.callback_url, + a.callback_events +from + notification_request_log r + join event e on r.event_id = e.id + join app a on a.name = e.app_name +where + {condition} +limit 1; +""" + + @classmethod + async def get_log_details( + cls, log_id: int, operator_event_id: Optional[str] = None + ): + query = cls.Queries.GET_CALLBACK_DETAILS + condition = None + values = [] + if operator_event_id: + condition = "r.operator_event_id = $1" + values = [str(operator_event_id)] + else: # when log_id is not -1 + condition = "r.id = $1" + values = [log_id] + + query = query.format(condition=condition) + + details = await ORMWrapper.raw_sql(query, values=values) + if len(details) != 1: + logger.error( + "exactly one row was expected from the query with log_id %s and operator_event_id %s but received %s", + log_id, + operator_event_id, + len(details), + ) + raise AssertionError( + f"Exactly one row was expected from the query but received {len(details)}" + ) + return details[0] diff --git a/app/repositories/event.py b/app/repositories/event.py index 0cd957b..41a5b06 100644 --- a/app/repositories/event.py +++ b/app/repositories/event.py @@ -169,4 +169,8 @@ async def get_all_templates( if not all_templates: raise NotFoundException("no templates found") return all_templates - \ No newline at end of file + + @classmethod + async def search_event(cls, event_like, order_by, limit, offset): + events = await EventDBModel.filter(event_name__contains=event_like).order_by(order_by).limit(limit).offset(offset) + return [await event.to_dict() for event in events] \ No newline at end of file diff --git a/app/repositories/notification_request_attempt.py b/app/repositories/notification_request_attempt.py index d44c5d9..81dd53b 100644 --- a/app/repositories/notification_request_attempt.py +++ b/app/repositories/notification_request_attempt.py @@ -2,7 +2,7 @@ from tortoise_wrapper.wrappers import ORMWrapper from app.models.notification_core_db import NotificationRequestAttemptDBModel - +from app.utilities.utils import truncate class NotificationRequestAttemptRepository: @classmethod @@ -18,17 +18,21 @@ async def log_new_notification_attempt( attempt_number, sent_at, metadata, + source, updated, + channel_status, ): log_data = { "channel": channel, "operator": operator, "operator_event_id": operator_event_id, "status": status, - "message": message, + "message": truncate(message), "sent_at": datetime.strptime(sent_at, "%B %d, %Y %H:%M:%S"), - "metadata": metadata, + "metadata": truncate(metadata), "updated": updated, + "source": source, + "channel_status": channel_status, } # `-1` represents log_id is unkown in this update # Trying to identify the row using operator_event_id diff --git a/app/repositories/notification_request_log.py b/app/repositories/notification_request_log.py index fb232cf..b51f63e 100644 --- a/app/repositories/notification_request_log.py +++ b/app/repositories/notification_request_log.py @@ -7,12 +7,14 @@ class NotificationRequestLogRepository: @classmethod - async def log_new_notification_request(cls, event_id, request_id, channel, sent_to, source_identifier): + async def log_new_notification_request(cls, status, event_id, request_id, channel, sent_to, message, source_identifier): log_data = { + 'status': status, 'event_id': event_id, 'notification_request_id': request_id, 'channel': channel, 'sent_to': sent_to, + 'message': message, 'source_identifier': source_identifier } new_rows = await ORMWrapper.create(NotificationRequestLogDBModel, log_data) @@ -20,29 +22,43 @@ async def log_new_notification_request(cls, event_id, request_id, channel, sent_ @classmethod async def update_notification_request_log(cls, log_id, **kwargs): - where = {'id': log_id} + exclude = {} + filter = {'id': log_id} + + if kwargs.get("source") == 'INTERNAL': + # if the coming source is INTERNAL, then don't update + # the row if the current source is WEBHOOKg + # + # This prevents SUCCESS status being overwritten by FAILED, QUEUED.. status + exclude = {'source': 'WEBHOOK'} + if log_id == '-1': operator_event_id = kwargs.pop("operator_event_id", None) if not operator_event_id: - raise AssertionError("operator_event_id can not be None if `log_id` is `-1`") - return await cls._update_notification_request_log_by_operator_event_id(operator_event_id, **kwargs) - return await cls._update_notification_request_log(where, **kwargs) + raise AssertionError( + "operator_event_id can not be None if `log_id` is `-1`" + ) + return await cls._update_notification_request_log_by_operator_event_id( + operator_event_id, exclude, **kwargs + ) - @classmethod - async def _update_notification_request_log_by_operator_event_id(cls, operator_event_id, **kwargs): - where = {'operator_event_id': operator_event_id} - return await cls._update_notification_request_log(where, **kwargs) + return await cls._update_notification_request_log(filter, exclude, **kwargs) @classmethod - async def _update_notification_request_log(cls, where, **kwargs): + async def _update_notification_request_log_by_operator_event_id(cls, operator_event_id, exclude, **kwargs): + filter = {'operator_event_id': operator_event_id} + return await cls._update_notification_request_log(filter, exclude, **kwargs) + @classmethod + async def _update_notification_request_log(cls, filter, exclude, **kwargs): if kwargs.get('message'): kwargs['message'] = cls.truncate_value(kwargs['message'], 5000) if kwargs.get('metadata'): kwargs['metadata'] = cls.truncate_value(kwargs['metadata'], 5000) - updated_rows = await ORMWrapper.update_with_filters( - None, NotificationRequestLogDBModel, kwargs, where_clause=where + return ( + await NotificationRequestLogDBModel.filter(**filter) + .exclude(**exclude) + .update(**kwargs) ) - return updated_rows @classmethod async def update_notification_request_with_where_clause(cls, where_clause, **update_values): diff --git a/app/routes/__init__.py b/app/routes/__init__.py index ccea59c..f55d5dc 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,33 +1,33 @@ from sanic import Blueprint + +from .apps import apps_blueprint from .prepare_notification import notify_bp from .notification_status import notification_status_blueprint -from .event import event_blueprint -from app.routes.channel_partners import channel_partners_apis -from app.routes.email import email_apis -from app.routes.push import push_apis -from app.routes.sms import sms_apis -from app.routes.whatsapp import whatsapp_apis -from app.routes.apps import apps_blueprint -from app.routes.form.structure import form_bp -from app.routes.dashboard.home_page import homepage_blueprint -from app.routes.dashboard.providers import providers_blueprint -from app.routes.dashboard.settings import settings_blueprint -from app.routes.dashboard.activity_feed import activity_feed_blueprint - - +from .event import event_blueprint, event_with_auth, internal_event_blueprint +from .email import email_api_with_auth, email_blueprint +from .push import push_apis_with_auth +from .sms import sms_apis_with_auth +from .whatsapp import whatsapp_apis_with_auth +from .form.structure import form_bp +from .dashboard.providers import providers_blueprint +from .dashboard.home_page import homepage_blueprint +from .dashboard.settings import settings_blueprint +from .channel_partners import channel_partners_apis blueprint_group = Blueprint.group( notification_status_blueprint, event_blueprint, + event_with_auth, + internal_event_blueprint, notify_bp, - email_apis, - push_apis, - sms_apis, - whatsapp_apis, + email_api_with_auth, + email_blueprint, + push_apis_with_auth, + sms_apis_with_auth, + whatsapp_apis_with_auth, apps_blueprint, form_bp, - homepage_blueprint, providers_blueprint, + homepage_blueprint, settings_blueprint, - activity_feed_blueprint, channel_partners_apis ) diff --git a/app/routes/apps.py b/app/routes/apps.py index 5bd713d..4688f5d 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -42,3 +42,14 @@ async def update_app(request: Request): request_payload = request.custom_json() data = await AppService.update_app(request_payload) return send_response(data) + +@apps_blueprint.route("/apps/", methods=["DELETE"], name="delete_app") +async def delete_app(request: Request, app_id: int): + data = await AppService.delete_app(app_id) + return send_response(data) + +@apps_blueprint.route("/apps/", methods=["GET"], name="get_app_by_id") +async def get_app_by_id(request: Request, app_id: int): + data = await AppService.get_app_by_id(app_id) + return send_response(data) + diff --git a/app/routes/dashboard/__init__.py b/app/routes/dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/routes/dashboard/providers.py b/app/routes/dashboard/providers.py index ee70e0f..128524a 100644 --- a/app/routes/dashboard/providers.py +++ b/app/routes/dashboard/providers.py @@ -8,8 +8,10 @@ @providers_blueprint.route("", methods=["POST"], name="add_new_provider") async def add_new_provider(request: Request): + print(f"Received request: {request.custom_json()}") data = request.custom_json() resp = await DashboardProvidersScreen.add_new_provider(data) + print("Response from add_new_provider:", resp) return send_response(resp) diff --git a/app/routes/email.py b/app/routes/email.py index dd5e9f8..50a9ac7 100644 --- a/app/routes/email.py +++ b/app/routes/email.py @@ -2,11 +2,20 @@ from sanic import Blueprint from app.manager.email_manager import EmailManager from app.exceptions import RequiredParamsException +from app.routes.middleware.authentication import HttpRequestAuthentication -email_apis = Blueprint("EmailAPIs") +email_blueprint = Blueprint("email", version=4) +email_api_with_auth = Blueprint("EmailAPIs") +@email_api_with_auth.on_request +async def request_authenticator(request: Request): + """ + Middleware to authenticate each incoming request of create event + """ + await HttpRequestAuthentication.examine_request(request) -@email_apis.route("/email/template", methods=["PUT"], name="update_email_template") + +@email_api_with_auth.route("/email/template", methods=["PUT"], name="update_email_template") async def update_email_template(request: Request): payload = request.custom_json() user_email = "admin@ns.com" @@ -21,7 +30,7 @@ async def update_email_template(request: Request): ) return send_response(result) -@email_apis.route( +@email_api_with_auth.route( "/email/template/preview", methods=["POST"], name="preview email_template" ) async def get_email_template_previews(request: Request): @@ -40,7 +49,7 @@ async def get_email_template_previews(request: Request): ) return send_response(result) -@email_apis.route( +@email_api_with_auth.route( "/email/include/template", methods=["POST"], name="create_include_email_template" ) async def create_include_email_template(request: Request): @@ -56,3 +65,35 @@ async def create_include_email_template(request: Request): name, content, description, subject, user_email ) return send_response(result) + +@email_blueprint.route("/email/template", methods=["GET"], name="get_email_template") +async def get_email_template(request: Request): + request_params = request.request_params() + template_id = request_params.get("id") + data = await EmailManager.get_email_template_by_id(template_id) + return send_response(data=data) + +@email_api_with_auth.route("/email/subtemplate/", methods=["PUT"], name="update_email_subtemplate") +async def update_email_subtemplate(request: Request, id: int): + payload = request.custom_json() + user_email = request.ctx.user + content, description = ( + payload.get("content"), + payload.get("description"), + ) + result = await EmailManager.update_email_subtemplate(int(id), content, description, user_email) + return send_response(result) + +@email_api_with_auth.route("/email/preview", methods=["POST"], name="preview_email_subtemplate") +async def preview_email_subtemplate(request: Request): + request_params = request.request_params() + payload = request.custom_json() + start = request_params.get("start") + size = request_params.get("size") + template_id = payload.get("id") + description = payload.get("description") + content = payload.get("content") + user_email = request.ctx.user + result = await EmailManager.preview_email_subtemplate(template_id, description, content, user_email, int(start), int(size)) + return send_response(result) + diff --git a/app/routes/event.py b/app/routes/event.py index a018b8d..826b214 100644 --- a/app/routes/event.py +++ b/app/routes/event.py @@ -2,13 +2,21 @@ from sanic_openapi import openapi from torpedo import Request, send_response from torpedo.exceptions import BadRequestException +from torpedo.internal_apis import InternalApiBlueprint from app.services import Events from app.services.email import EmailHandler from app.utilities import current_epoch from app.routes.api_models.event import CreateEventApiModel +from app.routes.middleware.authentication import HttpRequestAuthentication event_blueprint = Blueprint("Events") +event_with_auth = Blueprint("event_with_auth", version=4) +internal_event_blueprint = InternalApiBlueprint("event_internal") +@event_with_auth.on_request +async def request_authenticator(request: Request): + # Middleware to authenticate each incoming request of create event + await HttpRequestAuthentication.examine_request(request) @event_blueprint.route("/events/custom", methods=["GET"], name="get_events_custom") async def get_events_custom(request: Request): @@ -31,7 +39,7 @@ async def handle_email_template_update(request: Request): data = {"message": "success"} return send_response(data=data) -@event_blueprint.route(CreateEventApiModel.uri(), methods=[CreateEventApiModel.http_method()], name=CreateEventApiModel.name()) +@event_with_auth.route(CreateEventApiModel.uri(), methods=[CreateEventApiModel.http_method()], name=CreateEventApiModel.name()) @openapi.definition( summary=CreateEventApiModel.summary(), description=CreateEventApiModel.description(), @@ -48,10 +56,10 @@ async def create_event(request: Request): result = await Events.create_event(data) return send_response(result) -@event_blueprint.route("/event/add_action", methods=["PUT"], name="update_event") +@event_with_auth.route("/event/add_action", methods=["PUT"], name="update_event") async def update_event(request: Request): data = request.custom_json() - data["user_email"] = "temp@ns.com" + data["user_email"] = request.ctx.user result = await Events.add_new_action_to_event(data) return send_response(result) @@ -69,10 +77,64 @@ async def get_events(request: Request): result = await Events.get_events(request.args) return send_response(result) -@event_blueprint.route( +@event_with_auth.route( "/event/", methods=["DELETE"], name="delete_event_with_id" ) async def delete_event(request: Request, event_id: int): - user_email = "temp@ns.com" + user_email = request.ctx.user result = await Events.delete_event(event_id, user_email) return send_response(result) + +@event_with_auth.route("/event/", methods=["PUT"], name="update_event") +async def update_event(request: Request, event_id: int): + data = request.custom_json() + data["user_email"] = request.ctx.user + if event_id is None: + raise BadRequestException("event id is missing in the url") + result = await Events.update_event_data(event_id, data) + return send_response(result) + +@event_blueprint.route("/event-names", methods=["GET"], name="get_event_names") +async def get_event_names(request: Request): + result = await Events.get_event_names(request.args) + return send_response(result) + +@event_blueprint.route( + "/event/render-content", + methods=["POST"], + name="get_content_for_event", +) +async def get_content_for_event(request: Request): + payload = request.custom_json() + event_name = payload.get("event_name") + application_name = payload.get("application_name") + result = await Events.get_content_for_event(event_name=event_name, body=payload, application_name=application_name) + return send_response(data=result) + + +@internal_event_blueprint.route( + "/v4/event/content", + methods=["POST"], + name="get_content_for_event", +) +async def handle_get_event_content_for_lara_request(request: Request): + payload = request.custom_json() + event_name = payload.get("event_name") + application_name = payload.get("application_name") + result = await Events.get_content_for_event( + event_name=event_name, body=payload, application_name=application_name + ) + response = {} + + if result.get("email", {}).get("body"): + response.update( + { + "email": result.get("email", {}).get("body"), + "subject": result.get("email", {}).get("subject"), + } + ) + if result.get("sms", {}).get("body"): + response.update({"sms": result.get("sms", {}).get("body")}) + return send_response(data=response) + + diff --git a/app/routes/form/structure.py b/app/routes/form/structure.py index 0d578ab..831a83c 100644 --- a/app/routes/form/structure.py +++ b/app/routes/form/structure.py @@ -1,4 +1,3 @@ -from lazy_object_proxy.utils import await_ from sanic import Blueprint from sanic.response import json from sanic_openapi.openapi2.doc import response @@ -6,8 +5,10 @@ from app.constants import NotificationChannels, Providers from app.services.form.forms import CreateAppForm, CreateEventForm, UpdateAppForm +from app.manager.event_manager import EventMetaData from app.services.form.forms.add_provider.parent import AddProviderForm from app.services.form.forms.update_provider.parent import UpdateProviderForm +from app.services.form.forms.update_event.parent import UpdateEventForm form_bp = Blueprint("form", url_prefix="form-structure/") @@ -42,4 +43,10 @@ async def get_add_provider_form(_req, channel, provider): async def get_update_provider_form(_req, unique_identifier): if not unique_identifier: raise BadRequestException("Missing unique_identifier") - return json(body=await UpdateProviderForm.get_instance_asdict(unique_identifier)) \ No newline at end of file + return json(body=await UpdateProviderForm().get_instance_asdict(unique_identifier)) + +@form_bp.get("/update-event/") +async def get_update_event_form(_req, event_id: int): + form_structure = await UpdateEventForm(event_id).get_asdict_new() + meta_data = await EventMetaData(event_id).get_meta_data() + return json(body={"form_structure": form_structure, "meta_data": meta_data}) \ No newline at end of file diff --git a/app/routes/middleware/authentication.py b/app/routes/middleware/authentication.py new file mode 100644 index 0000000..60d808b --- /dev/null +++ b/app/routes/middleware/authentication.py @@ -0,0 +1,47 @@ +from app.exceptions import ( + UnAuthorizeException, + ForbiddenException, + ResourceNotFoundException +) +from app.service_clients.auth.auth import AuthClient +from torpedo.exceptions import BadRequestException + +class HttpRequestAuthentication: + APP_NAME = "lara" + LARA_APP = "LARA_APP" + roles = ["LARA_ADMIN"] + + @classmethod + async def examine_request(cls, request, *args): + auth_token = request.headers.get("Authorization", None) + await cls.validate_authorization( + auth_token=auth_token, request=request, roles=cls.roles + ) + + @classmethod + async def validate_authorization(cls, auth_token, request, roles): + if auth_token is None: + raise BadRequestException("auth token is required to perform action") + try: + user_result = await AuthClient.authenticate(auth_token=auth_token) + except ResourceNotFoundException: + raise ForbiddenException( + "authorization failed: invalid or expired authentication token" + ) + cls.validate_roles(user_object=user_result, roles=roles) + request.ctx.user = user_result.get("email") + + @classmethod + def validate_roles(cls, user_object, roles): + if user_object is None or not len( + [app for app in [cls.APP_NAME, cls.LARA_APP] if app in user_object["roles"]] + ): + raise UnAuthorizeException("user is not authorized") + authenticated = False + for app in [cls.APP_NAME, cls.LARA_APP]: + for user_role in user_object["roles"].get(app, []): + if user_role in roles: + authenticated = True + if not authenticated: + raise ForbiddenException("user is not permitted") + \ No newline at end of file diff --git a/app/routes/push.py b/app/routes/push.py index 658cfff..f3cf261 100644 --- a/app/routes/push.py +++ b/app/routes/push.py @@ -2,11 +2,18 @@ from sanic import Blueprint from app.manager.push_notification_manager import PushManager from app.exceptions import RequiredParamsException +from app.routes.middleware.authentication import HttpRequestAuthentication -push_apis = Blueprint("PushAPIs") +push_apis_with_auth = Blueprint("PushAPIs") +@push_apis_with_auth.on_request +async def request_authenticator(request: Request): + """ + Middleware to authenticate each incoming request of create event + """ + await HttpRequestAuthentication.examine_request(request) -@push_apis.route("/push/template", methods=["PUT"], name="update_push_template") +@push_apis_with_auth.route("/push/template", methods=["PUT"], name="update_push_template") async def update_push_template(request: Request): payload = request.custom_json() user_email = "temp@ns.com" @@ -22,7 +29,7 @@ async def update_push_template(request: Request): message = {"message": "push notification has been updated sucessfully"} return send_response(message) -@push_apis.route( +@push_apis_with_auth.route( "/push/template/preview", methods=["POST"], name="preview_push_template" ) async def get_push_template_previews(request: Request): diff --git a/app/routes/sms.py b/app/routes/sms.py index 16f6700..9390230 100644 --- a/app/routes/sms.py +++ b/app/routes/sms.py @@ -2,11 +2,18 @@ from sanic import Blueprint from app.manager.sms_manager import SmsManager from app.exceptions import RequiredParamsException +from app.routes.middleware.authentication import HttpRequestAuthentication -sms_apis = Blueprint("SmsAPIs") +sms_apis_with_auth = Blueprint("SmsAPIs") +@sms_apis_with_auth.on_request +async def request_authenticator(request: Request): + """ + Middleware to authenticate each incoming request of create event + """ + await HttpRequestAuthentication.examine_request(request) -@sms_apis.route("/sms/template", methods=["PUT"], name="update_sms_template") +@sms_apis_with_auth.route("/sms/template", methods=["PUT"], name="update_sms_template") async def update_sms_template(request: Request): payload = request.custom_json() user_email = "temp@ns.com" @@ -22,7 +29,7 @@ async def update_sms_template(request: Request): message = {"message": "sms template has been updated sucessfully"} return send_response(message) -@sms_apis.route( +@sms_apis_with_auth.route( "/sms/template/preview", methods=["POST"], name="preview_sms_template" ) async def get_sms_template_previews(request: Request): diff --git a/app/routes/whatsapp.py b/app/routes/whatsapp.py index 8ecc2d2..3a03922 100644 --- a/app/routes/whatsapp.py +++ b/app/routes/whatsapp.py @@ -2,11 +2,19 @@ from sanic import Blueprint from app.manager.whatsapp_manager import WhatsappManager from app.exceptions import RequiredParamsException +from app.routes.middleware.authentication import HttpRequestAuthentication -whatsapp_apis = Blueprint("WhatsappAPIs") +whatsapp_apis_with_auth = Blueprint("WhatsappAPIs") +@whatsapp_apis_with_auth.on_request +async def request_authenticator(request: Request): + """ + Middleware to authenticate each incoming request of create event + """ + await HttpRequestAuthentication.examine_request(request) -@whatsapp_apis.route( + +@whatsapp_apis_with_auth.route( "/whatsapp/template", methods=["PUT"], name="update_whatsapp_template" ) async def update_whatsapp_template(request: Request): diff --git a/app/service.py b/app/service.py index abd810f..2a2008a 100644 --- a/app/service.py +++ b/app/service.py @@ -1,5 +1,4 @@ from torpedo import Host, CONFIG -from redis_wrapper import RegisterRedis from app.listeners import listeners @@ -8,9 +7,6 @@ if __name__ == "__main__": - # Setup redis wrapper - RegisterRedis.register_redis_cache(CONFIG.config["REDIS_CACHE_HOSTS"]) - # Register listeners Host._listeners = listeners diff --git a/app/service_clients/__init__.py b/app/service_clients/__init__.py index e69de29..b391829 100644 --- a/app/service_clients/__init__.py +++ b/app/service_clients/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["AuthClient","LambdaClient"] + +from .auth import AuthClient +from .aws_lambda import LambdaClient diff --git a/app/service_clients/auth/__init__.py b/app/service_clients/auth/__init__.py new file mode 100644 index 0000000..901eff4 --- /dev/null +++ b/app/service_clients/auth/__init__.py @@ -0,0 +1,2 @@ +__all__=["AuthClient"] +from .auth import AuthClient diff --git a/app/service_clients/auth/auth.py b/app/service_clients/auth/auth.py new file mode 100644 index 0000000..fa6343a --- /dev/null +++ b/app/service_clients/auth/auth.py @@ -0,0 +1,36 @@ +from torpedo import CONFIG +from torpedo.parser import BaseApiResponseParser +from ..base_api_client import APIClient + + +class AuthClient(APIClient): + """ + auth service client + """ + auth_config = CONFIG.config['AUTH'] + _host = auth_config['HOST'] + _timeout = auth_config['TIMEOUT'] + _parser = BaseApiResponseParser + + @classmethod + async def get_devices_by_email_id(cls, email_id: str): + path = '/v6/devices' + payload = { + 'email': email_id + } + devices = await cls.post(path, data=payload) + return devices.data + + @classmethod + async def authenticate(cls, auth_token): + """ + This is used for authenticating given auth by calling identity service. + :param auth_token: auth token which will be authenticated. + :return: dict of response from identity service. + """ + path = '/v6/authenticate' + payload = { + 'authentication_token': auth_token + } + response = await cls.post(path, data=payload) + return response.data \ No newline at end of file diff --git a/app/service_clients/aws_lambda/__init__.py b/app/service_clients/aws_lambda/__init__.py new file mode 100644 index 0000000..dcf4bc2 --- /dev/null +++ b/app/service_clients/aws_lambda/__init__.py @@ -0,0 +1,2 @@ +__all__=["LambdaClient"] +from .aws_lambda import LambdaClient \ No newline at end of file diff --git a/app/service_clients/aws_lambda/aws_lambda.py b/app/service_clients/aws_lambda/aws_lambda.py new file mode 100644 index 0000000..73aac2a --- /dev/null +++ b/app/service_clients/aws_lambda/aws_lambda.py @@ -0,0 +1,19 @@ +from torpedo import CONFIG +from ..base_api_client import APIClient + +class LambdaClient(APIClient): + """ + auth service client + """ + _lambda_config = CONFIG.config['LAMBDA'] + _host = _lambda_config['HOST'] + _timeout = 10 + + @classmethod + async def shorten_url(cls, long_url ): + request_uri = cls._lambda_config['URL_SHORTENER_URI'] + auth = cls._lambda_config['AUTH'] + data = {"auth": auth, "url": long_url} + response = await cls.post(path=request_uri, data=data, headers={'Content-Type': 'application/json'}) + return response + \ No newline at end of file diff --git a/app/service_clients/base_api_client.py b/app/service_clients/base_api_client.py new file mode 100644 index 0000000..038c84a --- /dev/null +++ b/app/service_clients/base_api_client.py @@ -0,0 +1,37 @@ +import aiotask_context as context +from torpedo import BaseApiRequest +from torpedo.constants import X_SHARED_CONTEXT +from app.utilities.parser import BaseHttpResponseParser + +class APIClient(BaseApiRequest): + """ + Base class for all inter service requests. Each method will return + + AsyncTaskResponse(self._data['data'], + meta=self._data.get('meta', None), + status_code=self._data['status_code'], + headers=headers) + """ + + _timeout = None + _parser = BaseHttpResponseParser + + @classmethod + def get_inter_service_headers( + cls, headers_keys: list = None, get_shared_context=False + ): + headers_keys = headers_keys or list() + _headers = {} + headers = {} + if headers: + if headers_keys: + for _key in headers_keys: + _headers[_key] = headers.get(_key) + else: + _headers = headers + if get_shared_context: + if context.get(X_SHARED_CONTEXT): + _headers[X_SHARED_CONTEXT] = context.get(X_SHARED_CONTEXT) + return _headers + + diff --git a/app/services/apps.py b/app/services/apps.py index f38aa11..2a29ec2 100644 --- a/app/services/apps.py +++ b/app/services/apps.py @@ -132,3 +132,30 @@ async def update_app(cls, request_payload): "name": app.name, "message": "App updated successfully" } + + @classmethod + async def delete_app(cls, app_id: int): + if app_id is None: + raise BadRequestException("App ID is mandatory param") + + app = await AppsRepository.delete_app( + app_id + ) + return { + "id": app.id, + "name": app.name, + "message": "App updated successfully" + } + + @classmethod + async def get_app_by_id(cls, app_id: int): + if app_id is None: + raise BadRequestException("App ID is mandatory param") + + app = await AppsRepository.get_app_by_id(app_id) + if not app: + raise BadRequestException("App does not exist") + + return app + + diff --git a/app/services/email.py b/app/services/email.py index 0c861ba..50040cb 100644 --- a/app/services/email.py +++ b/app/services/email.py @@ -43,31 +43,42 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ status = NotificationRequestLogStatus.INITIATED message = None email_body = None + print('Handling email request: {}'.format(request_body)) + # 'request_id': '54530a6d-aef2-4dc3-9006-d80cb14d14ek', 'app_name': 'test_app', 'event_name': 'test_event', 'event_id': '2', 'source_identifier': 'REQ_1234', 'to': {'email': ['akash.bhat@1mg.com'], 'mobile': ['9915870128'], 'device': []}, 'channels': {'email': {'sender': {'name': 'Akash Bhat', 'address': 'akash.bhat@1mg.com'}}, 'whatsapp': {}}, 'body': {}} + reply_to = request_body.get('email', {}).get('reply_to') or request_body.get('reply_to', Email.REPLY_TO) try: send_address = NotificationChannels.get_sent_to_for_channel(NotificationChannels.EMAIL.value, request_body) if not send_address: raise NoSendAddressFoundException(ErrorMessages.NO_SEND_ADDRESS_FOUND.value) - + print("Send address found: {}".format(send_address)) # Currently, we support only one email id in send_address send_address = send_address[0] if not is_notification_allowed_for_email(send_address): + print("Send address not allowed on test environment: {}".format(send_address)) raise NoSendAddressFoundException(ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value) - app = await AppsRepository.get_app_by_name(event.app_name) - if not app: - raise AppNotConfigured(ErrorMessages.APP_NOT_CONFIGURED.value) - email_subject, email_body = await cls.get_email_subject_and_body(event, request_body) + print("Email subject: {}".format(email_subject)) data = dispatch_notification_request_common_payload( event.id, event.event_name, event.app_name, NotificationChannels.EMAIL.value, notification_request_log_row.id ) - email_channel_data = request_body["channels"]["email"] - attachments = request_body['attachments'] - result = await cls.publish(app, email_channel_data, data, send_address, email_subject, email_body, attachments=attachments, priority=event.priority) + attachments = request_body.get('attachments') + cc = request_body.get('email').get('cc') + + if cc and not is_notification_allowed_for_email(",".join(cc)): + raise NoSendAddressFoundException(ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value) + + bcc = request_body.get('email').get('bcc') + email_channel_data = { + "cc": cc, + "bcc": bcc, + } + print("Publishing email with data: {}".format(data)) + result = await cls.publish(data, send_address, email_subject, email_body, reply_to, attachments, cc, bcc, event.priority) except NoSendAddressFoundException as ne: status = NotificationRequestLogStatus.NOT_ELIGIBLE message = str(ne) @@ -92,8 +103,8 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ # update notification request log status await NotificationRequestLog.update_notification_request_processed_status( notification_request_log_row.id, - message=message, - status=status.value, + message=result.message, + status=result.status.value, content=email_body, channel=NotificationChannels.EMAIL, request_id=request_body['request_id'], @@ -101,13 +112,13 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ return result @classmethod - async def publish(cls, app: AppsModel, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): + async def publish(cls, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): cc = email_channel_data.get('cc') bcc = email_channel_data.get('bcc') sender = { - 'reply_to': email_channel_data.get('reply_to') or app.email.reply_to, - 'name': email_channel_data.get('sender', {}).get('name') or app.email.sender.name, - 'address': email_channel_data.get('sender', {}).get('address') or app.email.sender.address + 'reply_to': email_channel_data.get('reply_to'), + 'name': email_channel_data.get('sender', {}).get('name'), + 'address': email_channel_data.get('sender', {}).get('address') } data.update({ 'subject': subject, @@ -122,6 +133,7 @@ async def publish(cls, app: AppsModel, email_channel_data, data, recipient, subj @classmethod async def get_email_subject_and_body(cls, event: EventModel, request_body: dict): + print("Getting email subject and body for event: {}".format(event.event_name)) email_body, raw_subject = await cls.get_email_details(event, request_body['body']) email_channel_data = request_body["channels"]["email"] subject = email_channel_data.get('subject') or raw_subject or '' diff --git a/app/services/event.py b/app/services/event.py index 0476964..006c865 100644 --- a/app/services/event.py +++ b/app/services/event.py @@ -1,6 +1,7 @@ import json import logging from typing import Dict, Optional +from tortoise.transactions import atomic from torpedo.exceptions import BadRequestException, NotFoundException from app.manager.email_manager import EmailManager from app.utilities.utils import validate_required_params, \ @@ -18,6 +19,11 @@ from app.manager.email_manager import CreateEmailEvent from app.manager.event_manager import EventManager from app.manager.whatsapp_manager import WhatsappManager,CreateWhatsappEvent +from app.services.notification_request import NotificationRequest +from app.services.email import EmailHandler +from app.services.sms import SMSHandler +from app.services.push import PushHandler +from app.services.whatsapp import WhatsappHandler logger = logging.getLogger() class Events: @@ -46,7 +52,7 @@ def get_event_actions(cls, data): return actions @classmethod - async def get_event_data(cls, data: dict, event_actions: Optional[Dict] = None, event_id: Optional[int] = None, + async def get_event_data(cls, data: dict, event_actions: Optional[Dict] = None, triggers_limit:Optional[Dict]= None, event_id: Optional[int] = None, creating_event: Optional[bool] = False): """ This function is used to get event data @@ -57,6 +63,7 @@ async def get_event_data(cls, data: dict, event_actions: Optional[Dict] = None, event_data = {} if creating_event: event_actions = get_default_actions() + triggers_limit = get_default_trigger_limits() app_name = data.get(Event.APP_NAME) event_name = data.get(Event.EVENT_NAME) @@ -64,27 +71,36 @@ async def get_event_data(cls, data: dict, event_actions: Optional[Dict] = None, if actions.get(NotificationChannels.EMAIL.value) and isinstance( actions.get(NotificationChannels.EMAIL.value), dict): + email = actions.get(NotificationChannels.EMAIL.value) event_data[NotificationChannels.EMAIL.value] = await CreateEmailEvent.get_data_for_event( actions.get(NotificationChannels.EMAIL.value), app_name, event_name, user_email, event_id) event_actions[NotificationChannels.EMAIL.value] = Action.ON.value + triggers_limit[NotificationChannels.EMAIL.value] = int(email.get(Event.TRIGGER_LIMIT)) if actions.get(NotificationChannels.SMS.value) and isinstance( actions.get(NotificationChannels.SMS.value), dict): + sms = actions.get(NotificationChannels.SMS.value) event_data[NotificationChannels.SMS.value] = await CreateSmsEvent.get_data_for_event( actions.get(NotificationChannels.SMS.value), user_email, event_id) event_actions[NotificationChannels.SMS.value] = Action.ON.value + triggers_limit[NotificationChannels.SMS.value] = int(sms.get(Event.TRIGGER_LIMIT)) if actions.get(NotificationChannels.PUSH.value) and isinstance( actions.get(NotificationChannels.PUSH.value), dict): + push = actions.get(NotificationChannels.PUSH.value) event_data[NotificationChannels.PUSH.value] = await CreatePushEvent.get_data_for_event( actions.get(NotificationChannels.PUSH.value), user_email, event_id) event_actions[NotificationChannels.PUSH.value] = Action.ON.value + triggers_limit[NotificationChannels.PUSH.value] = int(push.get(Event.TRIGGER_LIMIT)) if actions.get(NotificationChannels.WHATSAPP.value) and isinstance( actions.get(NotificationChannels.WHATSAPP.value), dict): + whatsapp = actions.get(NotificationChannels.WHATSAPP.value) event_data[NotificationChannels.WHATSAPP.value] = await CreateWhatsappEvent.get_data_for_event( actions.get(NotificationChannels.WHATSAPP.value), user_email, event_id) event_actions[NotificationChannels.WHATSAPP.value] = Action.ON.value + triggers_limit[NotificationChannels.WHATSAPP.value] = int(whatsapp.get(Event.TRIGGER_LIMIT)) + # not checking other event type (i.e, call) # in action because already they are restricted in getting action from payload. if not event_data: @@ -153,7 +169,12 @@ async def create_event(cls,data): 'subject': event_data.get('email', {}).get(Email.SUBJECT, ''), 'triggers_limit': json.dumps(trigger_limit), 'event_type': data.get('event_type'), - 'meta_info': json.dumps({Event.EVENT_PRIORITY: data.get(Event.EVENT_PRIORITY)}), + 'meta_info': json.dumps( + { + Event.EVENT_PRIORITY: data.get(Event.EVENT_PRIORITY), + Event.DYNAMIC_CHANNELS: data.get(Event.DYNAMIC_CHANNELS, False), + } + ), 'updated_by': data.get('user_email'), 'is_deleted': Event.SOFT_DELETE_DEFAULT_VALUE } @@ -191,8 +212,13 @@ async def add_new_action_to_event(cls, data): event_name=data['event_name'], app_name=data['app_name']) event_id = event_detail.get('id') event_actions = event_detail.get('actions') - event_data, event_actions = await cls.get_event_data(data=data, event_actions=event_actions, event_id=event_id) - values = {'updated_by': data.get('user_email'), 'actions': json.dumps(event_actions)} + triggers_limit=event_detail.get('triggers_limit') + event_data, event_actions, triggers_limit = await cls.get_event_data(data=data, event_actions=event_actions, triggers_limit=triggers_limit, event_id=event_id) + values = { + 'updated_by': data.get('user_email'), + 'actions': json.dumps(event_actions), + 'triggers_limit': json.dumps(triggers_limit), + } await EventRepository.update_event(app_name=data['app_name'], event_name=data['event_name'], values=values) @@ -291,3 +317,138 @@ async def delete_event(cls, event_id: int , user_email: str): return {"message": "event has been deleted sucessfully"} else: raise NotFoundException('event is not found') + + @classmethod + def _validate_payload_data(cls, data): + required_keys = Event.EVENT_UPDATE_REQUIRED_PARAMS + for key in required_keys: + if key not in data or data.get(key) is None: + raise BadRequestException(f"Key '{key}' is missing in the payload") + + @classmethod + @atomic() + async def update_event_data(cls, event_id, data): + app_name = data.get('app_name') + event_name = data.get('event_name') + event_type = data.get('event_type') + priority = data.get('priority') + callback_enabled = data.get('callback_enabled') + generic_data = data.get('payload') + updated_by = data.get('user_email') + email = data.get('email') + sms = data.get('sms') + push = data.get('push') + whatsapp = data.get('whatsapp') + dynamic_channels = data.get('dynamic_channels') + cls._validate_payload_data(data) + general_data = { + "app_name": app_name, + "event_name": event_name, + "event_id": event_id + } + if email and email.get('id'): + email.update(general_data) + email['triggers_limit'] = email.get('trigger_limit') + email['actions'] = int(email.get('enabled')) + await EmailManager.update_email_template(email.get('id'), updated_by, generic_data, update_event=True, payload=data.get('email')) + + if sms and sms.get('id'): + sms.update(general_data) + sms['triggers_limit'] = sms.get('trigger_limit') + sms['actions'] = int(sms.get('enabled')) + await SmsManager.update_sms_template(sms.get('id'), generic_data, updated_by, update_event=True, payload=data.get('sms')) + + if push and push.get('id'): + push.update(general_data) + push['triggers_limit'] = push.get('trigger_limit') + push['actions'] = int(push.get('enabled')) + await PushManager.update_push_notification(push.get('id'), generic_data, updated_by, update_event=True, payload=data.get('push')) + + if whatsapp and whatsapp.get('id'): + whatsapp.update(general_data) + whatsapp['triggers_limit'] = whatsapp.get('trigger_limit') + whatsapp['actions'] = int(whatsapp.get('enabled')) + await WhatsappManager.update_whatsapp_table(whatsapp.get('id'), updated_by, payload=data.get('whatsapp')) + + create_data = { + 'app_name': app_name, + 'event_name': event_name, + 'priority': priority, + 'event_type': event_type, + 'callback_enabled': callback_enabled, + 'user_email': updated_by, + 'email': email if email and not email.get('id') else None, + 'sms': sms if sms and not sms.get('id') else None, + 'push': push if push and not push.get('id') else None, + 'whatsapp': whatsapp if whatsapp and not whatsapp.get('id') else None, + + } + if create_data.get('email') or create_data.get('sms') or create_data.get('push') or create_data.get('whatsapp'): + await cls.add_new_action_to_event(create_data) + + await EventRepository.update_event_with_id( + event_id, + { + "callback_enabled": callback_enabled, + "event_type": event_type, + "meta_info": json.dumps({ + "priority": priority, + "dynamic_channels": dynamic_channels + }), + }, + ) + + return {"message": "event has been updated sucessfully"} + + @classmethod + async def get_event_names(cls, request_args): + event_like = request_args.get('event_like') + if not event_like: + raise BadRequestException('request should contain at least a letter of event name to search for events') + limit = int(request_args.get('per_page', 10)) + page_no = int(request_args.get('page_no', 0)) + result = [] + data = await EventRepository.search_event(event_like, Event.ID, limit, page_no*limit) + for event in data: + event_data = { + 'event_id': event.get('id'), + 'event_name': event.get('event_name'), + 'app_name': event.get('app_name') + } + result.append(event_data) + return {"events": result, "per_page": limit, "page_no": page_no} + + @classmethod + async def get_content_for_event(cls, event_name, application_name, body): + event_details = await EventRepository.get_event_details(app_name = application_name, event_name=event_name) + actions = event_details.get('actions', {}) + result = {} + NotificationRequest._transform_template_data(body) + for key, value in actions.items(): + if value: + if key == NotificationChannels.EMAIL.value: + email_subject, email_body = await EmailHandler.get_email_subject_and_body(EventModel(event_details), body) + result.update({ + 'email': { + 'body': email_body, + 'subject': email_subject + } + }) + elif key == NotificationChannels.SMS.value: + sms_data = await SMSHandler.get_sms_body(EventModel(event_details), body) + result.update({ + 'sms': { + 'body': sms_data + }, + }) + elif key == NotificationChannels.PUSH.value: + push_data = await PushHandler.get_push_content(EventModel(event_details), body) + result.update({ + 'push': push_data + }) + elif key == NotificationChannels.WHATSAPP.value: + whatsapp_data = await WhatsappHandler.get_whatsapp_content(EventModel(event_details), body) + result.update({ + 'whatsapp': whatsapp_data + }) + return result diff --git a/app/services/form/enums/field_type.py b/app/services/form/enums/field_type.py index 0fef2c3..811c664 100644 --- a/app/services/form/enums/field_type.py +++ b/app/services/form/enums/field_type.py @@ -24,6 +24,9 @@ "collection", "field_array", "dependent", + "CUSTOM_PREVIEW_EMAIL", + "CUSTOM_PREVIEW_SMS", + "CUSTOM_PREVIEW_PUSH" ] @@ -49,3 +52,6 @@ class FieldType(Enum): COLLECTION: _FieldType = "collection" FIELD_ARRAY: _FieldType = "field_array" DEPENDENT: _FieldType = "dependent" + CUSTOM_PREVIEW_EMAIL: _FieldType = "CUSTOM_PREVIEW_EMAIL" + CUSTOM_PREVIEW_SMS: _FieldType = "CUSTOM_PREVIEW_SMS" + CUSTOM_PREVIEW_PUSH: _FieldType = "CUSTOM_PREVIEW_PUSH" diff --git a/app/services/form/fields/__init__.py b/app/services/form/fields/__init__.py index b02382f..8e314ff 100644 --- a/app/services/form/fields/__init__.py +++ b/app/services/form/fields/__init__.py @@ -1,7 +1,8 @@ -from .base import BaseField, CollapseContainerConfig, LabelCol, WrapperCol +from .base import BaseField, CollapseContainerConfig, LabelCol, WrapperCol, CollapseConfig from .collection import Collection from .input import EmailInput, NumberInput, TextInput, UrlInput from .select import SelectField, Option from .switch import SwtichField from .text_area import TextArea +from .custom_field import CustomFieldEmail, CustomFieldSms, CustomFieldPush from .switch import SwtichField diff --git a/app/services/form/fields/base.py b/app/services/form/fields/base.py index c49797f..61e71b5 100644 --- a/app/services/form/fields/base.py +++ b/app/services/form/fields/base.py @@ -15,6 +15,7 @@ class BaseConfig: type: FieldType = field(init=False) label: Optional[str] = "Label" + initialValue: Any = None fieldKey: Optional[str] = None elementName: FieldPath = None absolutePath: FieldPath = None @@ -58,11 +59,16 @@ class BaseField(BaseConfig): BaseWrapper = BaseConfig +@dataclass +class CollapseConfig: + defaultActiveKey: Optional[Union[Number, List[Union[Number, str]], str]] = None + @dataclass class CollapseContainerConfig: canCollapse: bool = True panelConfig: Dict = field(default_factory=dict) + collapseConfig: Optional[CollapseConfig] = None @dataclass diff --git a/app/services/form/fields/custom_field.py b/app/services/form/fields/custom_field.py new file mode 100644 index 0000000..be36469 --- /dev/null +++ b/app/services/form/fields/custom_field.py @@ -0,0 +1,25 @@ + +from dataclasses import dataclass +from .base import BaseField, FieldType + + +@dataclass +class CustomFieldEmail(BaseField): + type: FieldType = FieldType.CUSTOM_PREVIEW_EMAIL + custom: bool = True + buttonType: str = "primary" + + +@dataclass +class CustomFieldSms(BaseField): + type: FieldType = FieldType.CUSTOM_PREVIEW_SMS + custom: bool = True + buttonType: str = "primary" + + +@dataclass +class CustomFieldPush(BaseField): + type: FieldType = FieldType.CUSTOM_PREVIEW_PUSH + custom: bool = True + buttonType: str = "primary" + diff --git a/app/services/form/fields/input.py b/app/services/form/fields/input.py index 8302499..eed0774 100644 --- a/app/services/form/fields/input.py +++ b/app/services/form/fields/input.py @@ -17,6 +17,7 @@ class TextInput(InputField): @dataclass class NumberInput(InputField): type: FieldType = FieldType.NUMBER + controls: bool = True @dataclass diff --git a/app/services/form/forms/__init__.py b/app/services/form/forms/__init__.py index 623901b..aedae2c 100644 --- a/app/services/form/forms/__init__.py +++ b/app/services/form/forms/__init__.py @@ -1,3 +1,4 @@ from .create_app import CreateAppForm from .create_event import CreateEventForm from .update_app import UpdateAppForm +from .update_event import UpdateEventForm diff --git a/app/services/form/forms/create_app/parent.py b/app/services/form/forms/create_app/parent.py index 91af324..0fca8b9 100644 --- a/app/services/form/forms/create_app/parent.py +++ b/app/services/form/forms/create_app/parent.py @@ -98,8 +98,8 @@ async def _get_components(cls): } @classmethod - async def get(cls): - components = await cls._get_components() + async def get(self): + components = await self._get_components() return Collection( name="create_app", label="Create App", diff --git a/app/services/form/forms/create_event/parent.py b/app/services/form/forms/create_event/parent.py index 37e80a6..f3e9422 100644 --- a/app/services/form/forms/create_event/parent.py +++ b/app/services/form/forms/create_event/parent.py @@ -49,6 +49,11 @@ async def _get_components(cls): label="Callback Enabled", span=12, ), + "dynamic_channels": SwtichField( + name="dynamic_channels", + label="Dynamic Channels", + span=12, + ), "priority": SelectField( name="priority", label="Priority", @@ -63,8 +68,8 @@ async def _get_components(cls): } @classmethod - async def get(cls): - components = await cls._get_components() + async def get(self): + components = await self._get_components() return Collection( name="create_event", label="Create Event", @@ -73,6 +78,7 @@ async def get(cls): "event_name", "event_type", "callback_enabled", + "dynamic_channels", "priority", "email", "sms", diff --git a/app/services/form/forms/create_event/push.py b/app/services/form/forms/create_event/push.py index 1ab801a..3892c48 100644 --- a/app/services/form/forms/create_event/push.py +++ b/app/services/form/forms/create_event/push.py @@ -1,4 +1,4 @@ -from app.constants.push import DeviceType, PushTarget +from app.constants.push import DeviceType, NotificationType, PushTarget from app.services.form.fields import ( Collection, CollapseContainerConfig, @@ -38,6 +38,12 @@ def _get_components(cls): rules=[Required], tooltip="maximum number of times a particular event can be triggered for a particular order (set -1 for infinite times)", # noqa ), + "type": SelectField( + name="type", + label="Type", + span=12, + options=Option.from_enum(NotificationType), + ), "title": TextInput(name="title", label="Title", rules=[Required]), "body": TextInput(name="body", label="Body", rules=[Required]), "target": SelectField( @@ -60,6 +66,7 @@ def get(cls): "device_type", "trigger_limit", "title", + "type" "body", "target", "image", diff --git a/app/services/form/forms/update_event/__init__.py b/app/services/form/forms/update_event/__init__.py new file mode 100644 index 0000000..be49759 --- /dev/null +++ b/app/services/form/forms/update_event/__init__.py @@ -0,0 +1 @@ +from .parent import UpdateEventForm diff --git a/app/services/form/forms/update_event/default_content.py b/app/services/form/forms/update_event/default_content.py new file mode 100644 index 0000000..0e77306 --- /dev/null +++ b/app/services/form/forms/update_event/default_content.py @@ -0,0 +1,47 @@ +from app.models.notification_core import EmailContentModel +from app.models.notification_core import SmsContentModel +from app.models.notification_core import PushNotificationModel +from app.models.notification_core import WhatsappContentModel + +NoneEmailContentModel = EmailContentModel({ + "id": None, + "name": None, + "path": None, + "description": None, + "event_id": None, + "subject": None, + "content": None, + "updated_by": None +}) + +NoneSmsContentModel = SmsContentModel({ + "id": None, + "event_id": None, + "content": None, + "updated_by": None +}) + +NonePushContentModel = PushNotificationModel({ + "id": None, + "event_id": None, + "title": None, + "body": None, + "target": None, + "image": None, + "type": None, + "device_type": None, + "device_version": None, + "updated_by": None, + "created": None, + "updated": None +}) + +NoneWhatsappContentModel = WhatsappContentModel({ + "id": None, + "event_id": None, + "name": None, + "updated_by": None, + "variable_mapping": None +}) + + diff --git a/app/services/form/forms/update_event/email.py b/app/services/form/forms/update_event/email.py new file mode 100644 index 0000000..5de0684 --- /dev/null +++ b/app/services/form/forms/update_event/email.py @@ -0,0 +1,53 @@ +from app.services.form.fields import ( + Collection, + CollapseContainerConfig, + TextInput, + NumberInput, + TextArea, + CustomFieldEmail, + CollapseConfig, + SwtichField +) +from app.services.form.fields.field_rule import Required + +class EmailForm: + def __init__(self, email_content, trigger_limit, enabled) -> None: + self._email_content = email_content + self._trigger_limit = trigger_limit if email_content.id else None + self._enabled = bool(email_content.id and enabled) + + def _get_components(self): + return { + "trigger_limit": NumberInput( + name="trigger_limit", + label="Trigger Limit", + initialValue=self._trigger_limit, + controls=False, + span=12, + tooltip="maximum number of times a particular event can be triggered for a particular order (set -1 for infinite times)", # noqa + ), + "enabled": SwtichField( + name="enabled", + label="Enabled", + initialValue=self._enabled, + span=12, + ), + "description": TextInput(name="description", label="Description", initialValue=self._email_content.description), + "subject": TextInput(name="subject", label="Subject", initialValue=self._email_content.subject), + "content": TextArea(name="content", label="Content", initialValue=self._email_content.content), + "preview": CustomFieldEmail(name="preview") + } + + def get(self): + components = self._get_components() + active_key = 0 if self._email_content.id else None + return Collection( + name="email", + label="Email", + order=["trigger_limit", "enabled", "description", "subject", "content", "preview"], + components=components, + collapseContainerConfig=CollapseContainerConfig( + canCollapse=True, + collapseConfig=CollapseConfig(defaultActiveKey=active_key), + ), + ) \ No newline at end of file diff --git a/app/services/form/forms/update_event/parent.py b/app/services/form/forms/update_event/parent.py new file mode 100644 index 0000000..aeaa596 --- /dev/null +++ b/app/services/form/forms/update_event/parent.py @@ -0,0 +1,116 @@ +from torpedo.exceptions import NotFoundException +from app.repositories.event import EventRepository +from app.constants.event import EventType +from app.models.notification_core import EventModel +from app.repositories.email_content import EmailContentRepository +from app.repositories.sms_content import SmsContentRepository +from app.repositories.push_notification import PushNotificationRepository +from app.repositories.whatsapp_content import WhatsappContentRepository + +from app.services.form.fields import ( + Collection, + SelectField, + TextInput, + SwtichField, + Option, +) +from app.services.form.fields.field_rule import Required +from app.utilities.utils import async_gather_dict +from app.services.form import GenericForm + + +from .email import EmailForm +from .sms import SmsForm +from .push import PushForm +from .whatsapp import WhatsAppForm +from .default_content import NoneEmailContentModel, NoneSmsContentModel, NonePushContentModel, NoneWhatsappContentModel + + +class UpdateEventForm(GenericForm): + def __init__(self, event_id) -> None: + self._event_id = event_id + + async def get_channel_content(self, event_id): + tasks = { + "email": EmailContentRepository.get_email_content_from_event_id(event_id), + "sms": SmsContentRepository.get_sms_content_from_event_id(event_id), + "push": PushNotificationRepository.get_push_notification(event_id), + "whatsapp": WhatsappContentRepository.get_whatsapp_content_from_event_id(event_id) + } + result = await async_gather_dict(tasks, return_exceptions=True) + return { + "email": result.get("email") if result.get("email") else NoneEmailContentModel, + "sms": result.get("sms") if result.get("sms") else NoneSmsContentModel, + "push": result.get("push") if result.get("push") else NonePushContentModel, + "whatsapp": result.get("whatsapp") if result.get("whatsapp") else NoneWhatsappContentModel, + } + + async def _get_components(self, event: EventModel, event_contents: dict): + component = { + "app_name": SelectField( + name="app_name", + label="App Name", + initialValue=event.app_name, + disabled=True, + rules=[Required], + ), + "event_name": TextInput( + name="event_name", label="Event Name", initialValue=event.event_name, disabled=True, rules=[Required] + ), + "event_type": SelectField( + name="event_type", + label="Event Type", + initialValue=event.event_type, + options=Option.from_enum(EventType), + rules=[Required], + ), + "callback_enabled": SwtichField( + name="callback_enabled", + label="Callback Enabled", + initialValue=event.callback_enabled, + span=12, + ), + "dynamic_channels": SwtichField( + name="dynamic_channels", + label="Dynamic Channels", + initialValue=event.dynamic_channel_allowed, + span=12, + ), + "priority": SelectField( + name="priority", + label="Priority", + initialValue=event.priority, + span=12, + disabled=True, + rules=[Required], + ), + "email": EmailForm(event_contents.get("email"), event.triggers_limit.get("email"), event.actions.get("email")).get(), + "sms": SmsForm(event_contents.get("sms"), event.triggers_limit.get("sms"), event.actions.get("sms")).get(), + "push": PushForm(event_contents.get("push"), event.triggers_limit.get("push"), event.actions.get("push")).get(), + "whatsapp": WhatsAppForm(event_contents.get("whatsapp"), event.triggers_limit.get("whatsapp"), event.actions.get("whatsapp")).get(), + } + return component + + async def get(self): + event = await EventRepository.get_events_by_id(self._event_id) + if not event: + raise NotFoundException(f"no event found for event id : {self._event_id}") + event_contents = await self.get_channel_content(self._event_id) + components = await self._get_components(event, event_contents) + return Collection( + name="update_event", + label="Update Event", + order=[ + "app_name", + "event_name", + "event_type", + "callback_enabled", + "dynamic_channels", + "priority", + "email", + "sms", + "push", + "whatsapp", + ], + components=components, + ) \ No newline at end of file diff --git a/app/services/form/forms/update_event/push.py b/app/services/form/forms/update_event/push.py new file mode 100644 index 0000000..babee87 --- /dev/null +++ b/app/services/form/forms/update_event/push.py @@ -0,0 +1,102 @@ +from app.constants.push import DeviceType, NotificationType, PushTarget +from app.services.form.fields import ( + Collection, + CollapseContainerConfig, + TextInput, + NumberInput, + Option, + SelectField, + CustomFieldPush, + CollapseConfig, + SwtichField, +) +from app.services.form.fields.field_rule import Required + + +class PushForm: + def __init__(self, push_content, trigger_limit, enabled) -> None: + self._push_content = push_content + self._trigger_limit = trigger_limit if push_content.id else None + self._enabled = bool(push_content.id and enabled) + + def __get_targets(self): + targets = [] + for target in PushTarget.TARGET.keys(): + targets.append(Option(value=target, label=target)) + + # finally add DYNAMIC TARGET + dynamic_target = PushTarget.DYNAMIC_TARGET.get("name") + targets.append(Option(value=dynamic_target, label=dynamic_target)) + return targets + + def _get_components(self): + return { + "trigger_limit": NumberInput( + name="trigger_limit", + label="Trigger Limit", + initialValue=self._trigger_limit, + controls=False, + span=12, + tooltip="maximum number of times a particular event can be triggered for a particular order (set -1 for infinite times)", # noqa + ), + "enabled": SwtichField( + name="enabled", + label="Enabled", + initialValue=self._enabled, + span=12, + ), + "device_type": SelectField( + name="device_type", + label="Device Type", + initialValue=self._push_content.device_type, + options=Option.from_enum(DeviceType), + ), + "type": SelectField( + name="type", + label="Type", + span=12, + initialValue=self._push_content.type, + options=Option.from_enum(NotificationType), + ), + "title": TextInput( + name="title", label="Title", initialValue=self._push_content.title + ), + "body": TextInput( + name="body", label="Body", initialValue=self._push_content.body + ), + "target": SelectField( + name="target", + label="Target", + initialValue=self._push_content.target, + options=self.__get_targets(), + tooltip="the location of page which opens the notification is clicked", + ), + "image": TextInput( + name="image", label="Image", initialValue=self._push_content.image + ), + "preview": CustomFieldPush(name="preview"), + } + + def get(self): + components = self._get_components() + active_key = 0 if self._push_content.id else None + return Collection( + name="push", + label="Push", + order=[ + "trigger_limit", + "enabled", + "device_type", + "type", + "title", + "body", + "target", + "image", + "preview", + ], + components=components, + collapseContainerConfig=CollapseContainerConfig( + canCollapse=True, + collapseConfig=CollapseConfig(defaultActiveKey=active_key), + ), + ) diff --git a/app/services/form/forms/update_event/sms.py b/app/services/form/forms/update_event/sms.py new file mode 100644 index 0000000..9e884b7 --- /dev/null +++ b/app/services/form/forms/update_event/sms.py @@ -0,0 +1,67 @@ +from textwrap import dedent +from app.services.form.fields import ( + Collection, + CollapseContainerConfig, + TextInput, + NumberInput, + TextArea, + CustomFieldSms, + CollapseConfig, + SwtichField +) +from app.services.form.fields.field_rule import Required + +class SmsForm: + def __init__(self, sms_content, trigger_limit, enabled) -> None: + self._sms_content = sms_content + self._trigger_limit = trigger_limit if sms_content.id else None + self._enabled = bool(sms_content.id and enabled) + + def _get_components(self): + return { + "trigger_limit": NumberInput( + name="trigger_limit", + label="Trigger Limit", + initialValue = self._trigger_limit, + controls=False, + span=12, + tooltip="maximum number of times a particular event can be triggered for a particular order (set -1 for infinite times)", # noqa + ), + "enabled": SwtichField( + name="enabled", + label="Enabled", + initialValue=self._enabled, + span=12, + ), + "content": TextArea(name="content", label="Content", initialValue=self._sms_content.content), + "preview": CustomFieldSms(name="preview") + } + + def get(self): + components = self._get_components() + active_key = 0 if self._sms_content.id else None + return Collection( + name="sms", + label="SMS", + order=[ + "trigger_limit", + "enabled", + "content", + "preview", + ], + components=components, + collapseContainerConfig=CollapseContainerConfig( + canCollapse=True, + collapseConfig=CollapseConfig(defaultActiveKey=active_key), + ), + tooltip=dedent( + """ + The following steps have to be performed in order to create a SMS + 1. Get the DLT approval for the SMS template + 2. Creating a new Event on notification system + 3. Adding the template to Plivo and/or SMS Country + + For more information: https://1-mg.in/wxsO8EPvO + """ + ), + ) diff --git a/app/services/form/forms/update_event/whatsapp.py b/app/services/form/forms/update_event/whatsapp.py new file mode 100644 index 0000000..cc3031d --- /dev/null +++ b/app/services/form/forms/update_event/whatsapp.py @@ -0,0 +1,52 @@ +from app.services.form.fields import ( + Collection, + CollapseContainerConfig, + TextInput, + NumberInput, + CollapseConfig, + SwtichField +) +from app.services.form.fields.field_rule import Required + +class WhatsAppForm: + def __init__(self, whatsapp_content, trigger_limit, enabled) -> None: + self._whatsapp_content = whatsapp_content + self._trigger_limit = trigger_limit if whatsapp_content.id else None + self._enabled = bool(whatsapp_content.id and enabled) + + def _get_components(self): + return { + "trigger_limit": NumberInput( + name="trigger_limit", + label="Trigger Limit", + initialValue=self._trigger_limit, + controls=False, + span=12, + tooltip="maximum number of times a particular event can be triggered for a particular order (set -1 for infinite times)", # noqa + ), + "enabled": SwtichField( + name="enabled", + label="Enabled", + initialValue=self._enabled, + span=12, + ), + "name": TextInput(name="name", label="Name", initialValue=self._whatsapp_content.name), + } + + def get(self): + components = self._get_components() + active_key = 0 if self._whatsapp_content.id else None + return Collection( + name="whatsapp", + label="WhatsApp", + order=[ + "trigger_limit", + "enabled", + "name", + ], + components=components, + collapseContainerConfig=CollapseContainerConfig( + canCollapse=True, + collapseConfig=CollapseConfig(defaultActiveKey=active_key), + ), + ) \ No newline at end of file diff --git a/app/services/logging.py b/app/services/logging.py index 0712ac5..bf03f99 100644 --- a/app/services/logging.py +++ b/app/services/logging.py @@ -6,18 +6,25 @@ from app.repositories.notification_request_attempt import ( NotificationRequestAttemptRepository, ) -from app.utilities import json_dumps, current_utc_timestamp +from app.utilities import json_dumps, current_utc_timestamp, truncate class NotificationRequestLog: @classmethod async def log_notification_request_for_channel(cls, event: EventModel, channel: NotificationChannels, request_body): "Log notification request for a channel" - send_addresses = NotificationChannels.get_sent_to_for_channel(channel.value, request_body) - sent_to = ",".join(send_addresses) - source_identifier = NotificationChannels.get_source_identifier_for_event(request_body) + status = NotificationRequestLogStatus.NEW.value + message = None + if event.dynamic_channel_allowed: + allowed_channels = request_body.get('channels') + if allowed_channels and channel.value not in allowed_channels: + status = NotificationRequestLogStatus.NOT_ELIGIBLE.value + message = "Channel not allowed to be triggered for this event" + + sent_to = NotificationChannels.get_sent_to_for_channel(channel.value, request_body) + source_identifier = NotificationChannels.get_source_identifier_for_event(event.event_name, request_body) return (await NotificationRequestLogRepository.log_new_notification_request( - event.id, request_body['request_id'], channel.value, sent_to, source_identifier + status, event.id, request_body['request_id'], channel.value, sent_to, message, source_identifier )) @classmethod @@ -26,11 +33,13 @@ async def log_notification_request(cls, event: EventModel, request_body: dict = Log a new notification request - all eligible channels in the notification event """ active_channels = event.get_active_channels() + print(f"Logging notification request for channels: {active_channels}") log_tasks = list() for channel in active_channels: channel = NotificationChannels.get_enum(channel) log_tasks.append(cls.log_notification_request_for_channel(event, channel, request_body)) result = await asyncio.gather(*log_tasks, return_exceptions=True) + print(f"Result of logging notification request: {result}") log_result = dict() for log_record_model in result: log_result[log_record_model.channel] = log_record_model @@ -47,6 +56,10 @@ async def update_notification_log(cls, log_id, **kwargs): update_data['operator'] = kwargs['operator'] if kwargs.get('operator_event_id'): update_data['operator_event_id'] = kwargs['operator_event_id'] + if kwargs.get('source'): + update_data['source'] = kwargs['source'] + if kwargs.get('channel_status'): + update_data['channel_status'] = kwargs['channel_status'] if kwargs.get('metadata'): update_data['metadata'] = json_dumps(kwargs['metadata']) if update_data: @@ -64,8 +77,10 @@ async def upsert_attempt_log(cls, log_id, **data): attempt_data["metadata"] = data.get("metadata") attempt_data["sent_at"] = data.get("sent_at") attempt_data["attempt_number"] = data.get("attempt_number") - attempt_data["sent_to"] = data.get("sent_to") + attempt_data["sent_to"] = truncate(data.get("sent_to"), max_chars=10000) if data.get("sent_to") else None attempt_data["channel"] = data.get("channel") + attempt_data["channel_status"] = data.get("channel_status") or "UNKNOWN" + attempt_data["source"] = data.get("source") or "UNKNOWN" attempt_data["updated"] = current_utc_timestamp() await NotificationRequestAttemptRepository.log_new_notification_attempt( log_id, **attempt_data @@ -105,7 +120,7 @@ async def update_notification_request_processed_status(cls, log_id, **kwargs): @classmethod async def get_triggered_count_for_limit_check(cls, event: EventModel, request_body: dict) -> dict: counts = {_channel: 0 for _channel in event.get_active_channels()} - source_identifier = NotificationChannels.get_source_identifier_for_event(request_body) + source_identifier = NotificationChannels.get_source_identifier_for_event(event.event_name, request_body) if source_identifier: triggered_notifications = await NotificationRequestLogRepository.get_notification_request_log( source_identifier=source_identifier, event_id=event.id) diff --git a/app/services/notification_request.py b/app/services/notification_request.py index b9a02ca..87a0011 100644 --- a/app/services/notification_request.py +++ b/app/services/notification_request.py @@ -5,8 +5,9 @@ from app.constants import NotificationChannels, NotificationRequestLogStatus from app.models.notification_core import EventModel from app.repositories.event import EventRepository +from app.manager.event_manager import EventManager from app.utilities.utils import json_loads, async_gather_dict - +from app.utilities.parser import V2PayloadParser from .email import EmailHandler from .sms import SMSHandler from .push import PushHandler @@ -48,11 +49,13 @@ async def handle_notification_request_sync(cls, request_body, message_receive_co @classmethod async def _handle_notification_request(cls, request_body, message_receive_count=1): - # request_body = V2PayloadParser.parse(request_body) + print('Handling notification request: {}'.format(request_body)) + request_body = V2PayloadParser.parse(request_body) app_name = request_body['app_name'] event_name = request_body['event_name'] event = await EventRepository.get_event(app_name, event_name) # Transform request body + logger.info('Transforming request body for event: {}'.format(event_name)) if request_body.get('body'): cls._transform_template_data(request_body['body']) @@ -65,19 +68,21 @@ async def _handle_notification_request(cls, request_body, message_receive_count= event.mute_channels(already_triggered_channels) else: # Upsert template preview data - # asyncio.create_task(LaraClient.upsert_event_preview_data(request_body)) - pass - + asyncio.create_task(EventManager.save_event_body(str(event.id), request_body.get('body'))) # log notification request in NEW status log_details = await NotificationRequestLog.log_notification_request(event, request_body) # run trigger limit check barred_channels = await cls.run_trigger_limit_check(event, request_body, log_details) # Get eligible channels - active_channels = event.get_active_channels() + # Get eligible channels + active_channels = event.get_allowed_channels(request_body.get('channels')) eligible_channels = set(active_channels).difference(barred_channels) tasks = {} # Dispatch notification request to respective handlers + print(f"Eligible channels for event {event_name}: {eligible_channels}") + print(f"Request body: {request_body}") + # return await EmailHandler.handle(event, request_body, log_details[NotificationChannels.EMAIL.value]) for channel in eligible_channels: if channel == NotificationChannels.EMAIL.value: tasks.update({"email": EmailHandler.handle(event, request_body, log_details[channel])}) @@ -94,10 +99,10 @@ async def run_trigger_limit_check(cls, event: EventModel, request_body: dict, lo barred_channels = list() if event.is_trigger_limit_enabled_for_any_active_channel(): triggered_counts = await NotificationRequestLog.get_triggered_count_for_limit_check(event, request_body) - active_channels = event.get_active_channels() + active_channels = event.get_allowed_channels(request_body.get('channels')) for channel in active_channels: if event.is_trigger_limit_enabled_for_channel(channel) and event.triggers_limit[channel] < triggered_counts[channel]: - logger.info('Trigger limit breached. Event - {}, Channel - {}, Identifier - {}'.format(event.event_name, channel, NotificationChannels.get_source_identifier_for_event(request_body))) + logger.info('Trigger limit breached. Event - {}, Channel - {}, Identifier - {}'.format(event.event_name, channel, NotificationChannels.get_source_identifier_for_event(event.event_name, request_body))) barred_channels.append(channel) asyncio.create_task( NotificationRequestLog.update_notification_log( diff --git a/app/services/push.py b/app/services/push.py index d2d235b..ec2bd12 100644 --- a/app/services/push.py +++ b/app/services/push.py @@ -1,5 +1,6 @@ import json import logging +from typing import Callable, List from torpedo import CONFIG from app.constants import ( NotificationChannels, @@ -11,6 +12,7 @@ from app.models.notification_core import EventModel from app.repositories.push_notification import PushNotificationRepository from app.service_clients.publisher import PublishResult +from app.service_clients import AuthClient from app.utilities import ( render_text, dispatch_notification_request_common_payload, @@ -49,25 +51,36 @@ async def handle( ErrorMessages.PUSH_EVENT_NOT_FOUND_IN_DB.value ) - send_address = NotificationChannels.get_sent_to_for_channel( - NotificationChannels.PUSH.value, request_body - ) - if not send_address: - raise NoSendAddressFoundException( - ErrorMessages.NO_SEND_ADDRESS_FOUND.value - ) + devices = await cls.get_devices(request_body) - devices = list() - for device in send_address: - d = {"register_id": device} - devices.append(d) + registered_devices = list() + for device in devices: + d = { + "user_id": device.get("user_id"), + "device_id": device.get("device_id"), + "device_os": device.get("device_os"), + "device_os_type": device.get("device_os_type"), + "register_id": device.get("register_id"), + "voip_token": device.get("voip_token"), + "device_token": device.get("device_token"), + "live_notification_token": ( + device.get("metadata") + if isinstance(device.get("metadata"), dict) + else {} + ).get("live_notification_token", {}), + "app_id": device.get("app_id"), + "application_name": device.get("application_name"), + } + registered_devices.append(d) push_data = { "title": await render_text(push_detail.title, request_body["body"]), "body": await render_text(push_detail.body, request_body["body"]), "target": await render_text(push_detail.target, request_body["body"]), "image": await render_text(push_detail.image, request_body["body"]), - "registered_devices": devices, + "details": request_body["push"], + "type": push_detail.type, + "registered_devices": registered_devices, } data = dispatch_notification_request_common_payload( event.id, @@ -95,8 +108,8 @@ async def handle( # update notification request log status await NotificationRequestLog.update_notification_request_processed_status( notification_request_log_row.id, - message=message, - status=status.value, + message=result.message, + status=result.status.value, content=json.dumps(push_data), channel=NotificationChannels.PUSH, request_id=request_body["request_id"], @@ -104,3 +117,48 @@ async def handle( if unhandled_exception: raise Exception("Unhandled exception in sending push") return result + + @classmethod + async def get_devices(cls, request_body): + """ + Get devices from the given list of recipients, + if not available then fetch from identity service using the email address + """ + + devices = request_body.get("to", {}).get("devices") + if devices: + return devices + + send_address = NotificationChannels.get_sent_to_for_channel( + NotificationChannels.PUSH.value, request_body + ) + if not send_address: + raise NoSendAddressFoundException(ErrorMessages.NO_SEND_ADDRESS_FOUND.value) + + if not is_notification_allowed_for_email(send_address): + raise NoSendAddressFoundException( + ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value + ) + + # The devices list received from auth is sorted on created data. + devices = await AuthClient.get_devices_by_email_id(send_address) + if devices and not devices["devices"]: + raise ResourceNotFoundException( + ErrorMessages.NO_REGISTERED_DEVICE_FOUND.value + ) + + # Consider max 5 devices created recently. + return devices["devices"][-MAX_DEVICES_FOR_PUSH::] + + @classmethod + async def get_push_content(cls, event: EventModel, _body: dict): + push_detail = await PushNotificationRepository.get_push_notification(event.id) + push_data = { + "title": await render_text(push_detail.title, _body["body"]), + "body": await render_text(push_detail.body, _body["body"]), + "target": await render_text(push_detail.target, _body["body"]), + "image": await render_text(push_detail.image, _body["body"]), + "type": push_detail.type + } + return push_data + diff --git a/app/services/sms.py b/app/services/sms.py index 265111b..fdf4331 100644 --- a/app/services/sms.py +++ b/app/services/sms.py @@ -9,6 +9,7 @@ from app.exceptions import NoSendAddressFoundException from app.models.notification_core import EventModel from app.service_clients.publisher import PublishResult +from app.repositories.notification_request_log import NotificationRequestLogRepository from app.utilities import ( get_transformed_message_by_text, dispatch_notification_request_common_payload, @@ -16,6 +17,7 @@ ) from .abstract_handler import AbstractHandler from .logging import NotificationRequestLog +from app.utilities import current_utc_timestamp from app.repositories.sms_content import SmsContentRepository logger = logging.getLogger() @@ -43,15 +45,23 @@ async def handle( ErrorMessages.NO_SEND_ADDRESS_FOUND.value ) - # Currently, we support only one mobile id in send_address - send_address = send_address[0] - if not is_notification_allowed_for_mobile(send_address): raise NoSendAddressFoundException( ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value ) notification_body = await cls.get_sms_body(event, request_body) + + if notification_body: + update_data = { + "updated": current_utc_timestamp(), + "content_length": len(notification_body) or None + } + + await NotificationRequestLogRepository.update_notification_request_with_where_clause( + {"id": notification_request_log_row.id}, **update_data + ) + data = dispatch_notification_request_common_payload( event.id, event.event_name, @@ -83,8 +93,8 @@ async def handle( # update notification request log status await NotificationRequestLog.update_notification_request_processed_status( notification_request_log_row.id, - message=message, - status=status.value, + message=result.message, + status=result.status.value, content=notification_body, channel=NotificationChannels.SMS, request_id=request_body["request_id"], diff --git a/app/services/status_updates.py b/app/services/status_updates.py index 3fed401..df9d9f3 100644 --- a/app/services/status_updates.py +++ b/app/services/status_updates.py @@ -2,6 +2,7 @@ from app.constants import NotificationRequestLogStatus from app.utilities import json_loads +from app.services.webhooks import WebhookController from .logging import NotificationRequestLog @@ -23,6 +24,7 @@ async def handle_status_update(cls, payload, subscriber_id, message_receive_coun logger.exception("Invalid status value received in the status update payload. Payload - {}".format(payload)) return True + notification_log_id = payload['notification_log_id'] status = payload.get('status') message = payload.get('message') or None metadata = payload.get('metadata') or None @@ -32,14 +34,16 @@ async def handle_status_update(cls, payload, subscriber_id, message_receive_coun attempt_number = payload.get('attempt_number') channel = payload.get("channel") sent_to = payload.get("sent_to") + source = payload.get("source") + detail = payload.get("detail") await NotificationRequestLog.update_notification_log( - payload['notification_log_id'], status=status, operator=operator, operator_event_id=operator_event_id, - message=message, metadata=metadata + notification_log_id, status=status, operator=operator, operator_event_id=operator_event_id, + message=message, source=source, metadata=metadata, channel_status=detail ) await NotificationRequestLog.upsert_attempt_log( - payload["notification_log_id"], + notification_log_id, status=status, operator=operator, operator_event_id=operator_event_id, @@ -48,6 +52,19 @@ async def handle_status_update(cls, payload, subscriber_id, message_receive_coun sent_at=sent_at, attempt_number=attempt_number, channel=channel, - sent_to=sent_to + source=source, + sent_to=sent_to, + channel_status=detail + ) + + await WebhookController.handle_webhook( + status=status, + source=source, + channel=channel, + timestamp=sent_at, + channel_status=detail, + log_id=notification_log_id, + attempt_number=attempt_number, + operator_event_id=operator_event_id, ) return True diff --git a/app/services/webhooks/__init__.py b/app/services/webhooks/__init__.py new file mode 100644 index 0000000..dbd743f --- /dev/null +++ b/app/services/webhooks/__init__.py @@ -0,0 +1 @@ +from .controller import WebhookController diff --git a/app/services/webhooks/controller.py b/app/services/webhooks/controller.py new file mode 100644 index 0000000..625cc5d --- /dev/null +++ b/app/services/webhooks/controller.py @@ -0,0 +1,142 @@ +from typing import Dict, Optional + +import logging +import json + +from torpedo.common_utils import CONFIG +from app.service_clients.base_api_client import APIClient + +from app.constants.notification_channels import NotificationChannels +from app.constants.status import ExecutionDetailsSource, ExecutionDetailsEventStatus +from app.repositories.base import BaseRepository + +config = CONFIG.config +logger = logging.getLogger() + + +class WebhookController: + @staticmethod + async def _fetch_callback_details( + channel: NotificationChannels, + log_id: int, + operator_event_id: Optional[str] = None, + ): + details = await BaseRepository.get_log_details( + log_id=int(log_id), operator_event_id=operator_event_id + ) + callback_enabled = details.pop("callback_enabled", False) + callback_events = details.pop("callback_events", {}) + return ( + details, + callback_enabled, + json.loads(callback_events).get(channel.value), + ) + + @staticmethod + def __create_payload( + status: str, timestamp: str, channel: NotificationChannels, details: Dict + ): + return { + "status": status, + "timestamp": timestamp, + "channel": channel.value, + "event": { + "id": details.get("event_id"), + "name": details.get("event_name"), + "app_name": details.get("app_name"), + }, + "recipient": details.get("sent_to"), + "source_identifier": details.get("source_identifier"), + "request_id": details.get("notification_request_id"), + "details": { + "operator": details.get("operator"), + "operator_event_id": details.get("operator_event_id"), + }, + } + + @staticmethod + def _should_send_callback( + attempt_number: int, + channel: NotificationChannels, + source: ExecutionDetailsSource, + status: ExecutionDetailsEventStatus, + ): + if source == ExecutionDetailsSource.WEBHOOK: + return True + + is_success = source == ExecutionDetailsSource.INTERNAL and status == ( + ExecutionDetailsEventStatus.QUEUED or ExecutionDetailsEventStatus.SUCCESS + ) + + max_attempts = config.get("MAX_ATTEMPTS", {}) + # check for attempt_number + 1 as attempt_number is 0 indexed + is_last_attempt = (attempt_number + 1) >= max_attempts.get( + str(channel.value).upper() + ) + return is_success or is_last_attempt + + @classmethod + async def handle_webhook( + cls, + log_id: int, + status: str, + source: str, + channel: str, + timestamp: str, + channel_status: str, + attempt_number: int, + operator_event_id: int = None, + ): + channel = NotificationChannels(channel) + try: + if not cls._should_send_callback( + channel=channel, + attempt_number=attempt_number, + source=ExecutionDetailsSource(source), + status=ExecutionDetailsEventStatus(status), + ): + logger.info( + "Not sending callback as this is not the final attempt for " + "log_id %s operator_event_id %s", + log_id, + operator_event_id, + ) + return + + ( + details, + callback_enabled, + allowed_events, + ) = await cls._fetch_callback_details( + channel, + log_id, + operator_event_id, + ) + + notification_status = channel_status or status + if not callback_enabled or notification_status not in allowed_events: + logger.info( + "Callback not enabled for the given status %s or event %s. Skipping for log_id %s operator_event_id %s", + notification_status, + details.get("event_name"), + log_id, + operator_event_id, + ) + return + + payload = cls.__create_payload( + notification_status, timestamp, channel, details + ) + url = details.get("callback_url") + + logger.info("sending POST request to url %s with payload %s", url, payload) + await APIClient.post( + url, data=payload, headers={"Content-Type": "application/json"} + ) + except Exception as err: + logger.error( + "Encountered error while handling webhook for log_id %s operator_event_id %s: %s", + log_id, + operator_event_id, + err, + ) diff --git a/app/services/whatsapp.py b/app/services/whatsapp.py index 4548c58..831b075 100644 --- a/app/services/whatsapp.py +++ b/app/services/whatsapp.py @@ -1,10 +1,11 @@ -from typing import List +from typing import List, Callable import logging from torpedo import CONFIG from app.constants import ( NotificationChannels, NotificationRequestLogStatus, ErrorMessages, + VariableMappingKeys, ) from app.exceptions import NoSendAddressFoundException from app.models.notification_core import EventModel @@ -12,6 +13,7 @@ from app.utilities import ( dispatch_notification_request_common_payload, is_notification_allowed_for_mobile, + utils ) from .abstract_handler import AbstractHandler from .logging import NotificationRequestLog @@ -41,10 +43,7 @@ async def handle( raise NoSendAddressFoundException( ErrorMessages.NO_SEND_ADDRESS_FOUND.value ) - - # Currently, we support only one mobile id in send_address - send_address = send_address[0] - + if not is_notification_allowed_for_mobile(send_address): raise NoSendAddressFoundException( ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value @@ -52,8 +51,10 @@ async def handle( request_body = request_body or dict() phone_number = send_address - whatsapp_channel_data = request_body["channels"]["whatsapp"] - body_values = whatsapp_channel_data.get("body_values", []) + whatsapp_body = request_body.get("whatsapp") + body_values = whatsapp_body.get("body_values", []) + header_values = whatsapp_body.get("header_values", []) + button_values = whatsapp_body.get("button_values", {}) attachments = request_body.get("attachments") data = dispatch_notification_request_common_payload( event.id, @@ -66,18 +67,28 @@ async def handle( await WhatsappContentRepository.get_whatsapp_content_from_event_id(event.id) ) template = whatsapp_data.name - # template = event.get_whatsapp_template_name() + if not body_values: + body_values = cls.get_field_values(request_body.get('body'), whatsapp_data.variable_mapping, VariableMappingKeys.BODY) + if not button_values: + button_values = cls.get_field_values(request_body.get('body'), whatsapp_data.variable_mapping, VariableMappingKeys.BUTTON, False) + if not header_values: + header_values = cls.get_field_values(request_body.get('body'), whatsapp_data.variable_mapping, VariableMappingKeys.HEADER) data.update( { "template": template, "body_values": body_values, + "header_values": header_values, + "button_values": button_values, "mobile": phone_number, } ) if attachments: data.update( { - "files": attachments, + "attachment_data": { + "attachments": attachments, + "filename": request_body.get("filename"), + } } ) result = await cls._dispatch_notification.publish( @@ -97,9 +108,9 @@ async def handle( # update notification request log status await NotificationRequestLog.update_notification_request_processed_status( notification_request_log_row.id, - message=message, - status=status.value, - content=cls._get_content(template, body_values), + message=result.message, + status=result.status.value, + content=cls._get_content(template, body_values, header_values, button_values), channel=NotificationChannels.WHATSAPP, request_id=request_body["request_id"], ) @@ -108,5 +119,82 @@ async def handle( return result @classmethod - def _get_content(self, template: str, body_values: List[str]): - return f"templateName: {template}\nValues: {body_values}" + def _get_content(self, template: str, body_values: List[str], header_values: List[str], button_values:dict): + return f"templateName: {template}\nBodyValues: {body_values}\nHeaderValues: {header_values}\nButtonValues: {button_values}" + + @classmethod + async def get_whatsapp_content(cls, event: EventModel, _body: dict): + whatsapp_content = WhatsappContentRepository.get_whatsapp_content_from_event_id(event.id) + whatsapp_data = { + 'name': whatsapp_content.name, + 'variable_mapping': whatsapp_content.variable_mapping, + } + return whatsapp_data + + @classmethod + def get_field_values(cls, body: dict, variable_mapping: dict, type: str=VariableMappingKeys.BODY, return_list: bool = True): + """ + variable_mapping = {'body': {'var1': 1, 'var2': [2, 4], 'a.b.var3': 3}} + body = {'var1': 'value', 'var2': 'value', 'a': {'b': {'var3': 'value'}}} + + The method creates a list of values in the order of variable_mapping and gives a list or a dic in response + response = {'0':['value1', 'value2'], '1':['value2'], '2', 'value3']}/['value1', 'value2', 'value3', 'value2'] + """ + if not variable_mapping: + return [] if return_list else {} + + variables = variable_mapping.get(type.value) + + if not variables: + return [] if return_list else {} + + if return_list: + values = cls._get_values( + variables, body, lambda x: x, lambda x: x - 1 + ) + else: + values = cls._get_values( + variables, body, lambda x: [x], lambda x: str(x - 1) + ) + + if return_list: + maxlen = utils.get_max(variables.values()) + values_list = [None] * maxlen + + for idx, value in values.items(): + values_list[idx] = value + + return values_list + + return values + + @classmethod + def _get_values( + cls, + variable_mapping: dict, + body: dict, + value_transform: Callable, + key_transform: Callable, + ): + values = {} + for key, indexes in variable_mapping.items(): + keys = key.split(".") + value = body + + for k in keys: + value = value.get(k) + if value is None: + break + + if value is not None: + value = value_transform(value) + + if isinstance(indexes, list): + for idx in indexes: + key = key_transform(idx) + values[key] = value + elif isinstance(indexes, int): + key = key_transform(indexes) + values[key] = value + + return values \ No newline at end of file diff --git a/app/utilities/aws/s3.py b/app/utilities/aws/s3.py index 1ff8d93..8f30f60 100644 --- a/app/utilities/aws/s3.py +++ b/app/utilities/aws/s3.py @@ -37,7 +37,9 @@ async def upload(self, bucket: str, content: str, key: str): """Upload a file to an S3 bucket""" # Upload the file try: + print("Uploading file %s to Bucket %s", key, bucket) client = await self.get_s3_client() + print("S3 Client: %s", client) response = await client.put_object(Body=content, Bucket=bucket, Key=key) except Exception as err: logger.error("Error uploading file %s to Bucket %s %s", key, bucket, err) diff --git a/app/utilities/drivers/templates/includes/footer_labs.jade b/app/utilities/drivers/templates/includes/footer_labs.jade index ba95ae7..02611b1 100644 --- a/app/utilities/drivers/templates/includes/footer_labs.jade +++ b/app/utilities/drivers/templates/includes/footer_labs.jade @@ -1,13 +1,14 @@ -.strip.footer - div.txt-support.link-email - a.txt-help(href="{{base_url or Hkp_url}}/contactUs") Need Help? - span Click to contact us for queries - .social-links - a.social-link(href='https://www.facebook.com/1mgOfficial', target='_blank', title='Like us on Facebook',rel='nofollow',data-value='Facebook') - img(src='https://s3.ap-south-1.amazonaws.com/1mg-auto-resize-image/pharmacy-production-rxs/366901c5-10c3-4653-baa5-2935a2e5e318.png') - a.social-link(href='https://twitter.com/1mgofficial',target='_blank',title='Follow us on Twitter', rel='nofollow', data-value='Twitter') - img(src='https://s3.ap-south-1.amazonaws.com/1mg-auto-resize-image/pharmacy-production-rxs/6b839a9c-e65f-40de-9de7-4f0e77f6d04d.png') - a.social-link(href='https://plus.google.com/+Healthkartplus', target='_blank', title='Follow us on Google+', rel='nofollow', data-value='Google') - img(src='https://s3.ap-south-1.amazonaws.com/1mg-auto-resize-image/pharmacy-production-rxs/b44f224a-d39a-4894-8c1c-91482edc756a.png' width="40") - a.social-link(href='https://www.linkedin.com/company/5230742', target='_blank', title='Visit us on LinkedIn', rel='nofollow', data-value='LinkedIn') - img(src='https://s3.ap-south-1.amazonaws.com/1mg-auto-resize-image/pharmacy-production-rxs/099b0ec7-e30a-4d7f-a7e6-d8a8e2a27b20.png') \ No newline at end of file +div.block-grid.no-stack(style="Margin: 0 auto; width: 100%; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: transparent;") + a.link-labs-banner(href='https://www.1mg.com/support/chat') + .lab-img + img(src="https://onemg.gumlet.io/assets/db640b78-353f-4588-baf5-c345ef032d7f.png?format=auto") + div(style="border-collapse: collapse; display: table; width: 100%; background-color: transparent;") + each val in [8, 7, 6, 5, 4, 3, 2, 1] + - var width = [9.5, 6, 7.33, 7.33, 46.5, 7.17, 7.5, 8.67][val - 1] + div(class="col num4" style="display: table-cell; vertical-align: top; width: #{width}%;") + div(style="width: 100% !important;") + div(style="border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent; padding-top: 0px; padding-bottom: 0px; padding-right: 0px; padding-left: 0px;") + div.img-container.center.autowidth(style="padding-right: 0px; padding-left: 0px; text-align: center;") + a(href="#{["https://www.instagram.com/1mgofficialpage/?hl=en", "https://www.facebook.com/1mgOfficial/", "https://www.youtube.com/@1mgofficial", "https://twitter.com/1mgOfficial", "#", "https://api.whatsapp.com/send/?phone=%2B9667758942", "https://apps.apple.com/in/app/tata-1mg-healthcare-app/id554578419", "https://play.google.com/store/apps/details?id=com.aranoah.healthkart.plus"][val - 1]}", style="outline:none", tabindex="-1", target="_blank") + img.center.autowidth(src="https://onemg.gumlet.io/marketing/#{['decd6fa2-1025-432b-888d-4e7267b80147.png', '63c3f3ee-d6b8-4073-a6a3-33c42918b529.png', '5bd5bb8d-b946-47b3-ac9a-7604094d3ee2.png', '1d3b14e7-087f-45a3-a4cc-acbe40322c0e.png', '77097879-0a38-4ff4-a038-c3248d45c723.png', '5bb58e89-7749-40a5-ac97-80920b96b531.png', 'f6d19c6b-46d0-4134-8d31-f56adebdb175.png', 'b7371e4d-01eb-4ba6-a62a-005f7c4f774f.png'][val - 1]}", style="text-decoration: none; -ms-interpolation-mode: bicubic; height:auto; border: none; width: 100%; display: block;", width="200") + div.overlay(style="top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255, 255, 255, 0.5);") diff --git a/app/utilities/drivers/templates/includes/styles.css b/app/utilities/drivers/templates/includes/styles.css index 97b57bf..f922bec 100644 --- a/app/utilities/drivers/templates/includes/styles.css +++ b/app/utilities/drivers/templates/includes/styles.css @@ -165,6 +165,23 @@ padding: 0 12px 5px; .div-header { /*max-height: 92px;*/ + background-color:transparent; +} + +.div-header-one { + Margin: 0 auto; + width: 600px; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + background-color: transparent; +} + +.div-header-two{ + border-collapse: collapse; + display:table; + width: 100%; + background-color:transparent; } .download-img { @@ -201,6 +218,47 @@ padding: 0 12px 5px; margin-bottom: 8px; } +.link-logo-labs-new { + outline: none +} +.div-inner-four{ + padding-right: 0px; + padding-left: 0px; +} +.div-inner-three{ + border-top: 0px solid transparent; + border-left: 0px solid transparent; + border-bottom: 0px solid transparent; + border-right: 0px solid transparent; + padding-top: 0px; + padding-bottom: 0px; + padding-right: 0px; + padding-left: 0px; +} +.div-inner-two{ + width: 100% !important; +} +.div-inner-one-one{ + display: table-cell; + vertical-align: top; + width: 387px; +} +.div-inner-one-two{ + display: table-cell; + vertical-align: top; + width: 68px; +} +.div-inner-one-three{ + display: table-cell; + vertical-align: top; + width: 696px; +} +.div-inner-one-four{ + display: table-cell; + vertical-align: top; + width: 76px; +} + .div-main a img { display: block; margin: 0 auto; @@ -646,6 +704,15 @@ padding: 0 12px 5px; margin-left: 16px; } +.email-logo-labs-new{ + text-decoration: none; + -ms-interpolation-mode: bicubic; + height: auto; + border: none; + width: 387px; + display: block; +} + .txt-help { margin-right: 4px; margin-left: 5px; @@ -944,6 +1011,30 @@ table[class="medicine-list-item"]{ width: 33%; } +.block-grid { + margin: 0 auto; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + background-color: transparent; +} +.img-container { + padding-right: 0px; + padding-left: 0px; + text-align: center; +} +img.center { + display: block; + width: 100%; + height: auto; + border: none; + -ms-interpolation-mode: bicubic; +} +.custom-col { + display: table-cell; + vertical-align: top; +} + /* _______________________________________________________________ | MEDIA QUERIES | |______________________________________________________________| diff --git a/app/utilities/drivers/templates/labs/booking_done.jade b/app/utilities/drivers/templates/labs/booking_done.jade index 2c2f5b3..fb76ce1 100755 --- a/app/utilities/drivers/templates/labs/booking_done.jade +++ b/app/utilities/drivers/templates/labs/booking_done.jade @@ -74,4 +74,3 @@ block content .card-content .subtle If you wish to get any tests done at home, Tata 1mg is offering a flat 25% discount for camp participants. The coupon code is OFF25. - include labs/regards.jade diff --git a/app/utilities/drivers/templates/labs/booking_stage.jade b/app/utilities/drivers/templates/labs/booking_stage.jade index 303c2d7..8edbd3b 100755 --- a/app/utilities/drivers/templates/labs/booking_stage.jade +++ b/app/utilities/drivers/templates/labs/booking_stage.jade @@ -210,5 +210,3 @@ block content .item {{order.consultation}} - - include labs/regards.jade diff --git a/app/utilities/drivers/templates/labs/regards.jade b/app/utilities/drivers/templates/labs/regards.jade index 8531684..e69de29 100755 --- a/app/utilities/drivers/templates/labs/regards.jade +++ b/app/utilities/drivers/templates/labs/regards.jade @@ -1,3 +0,0 @@ -.regards - div Thank you for using Tata 1mg ! - .subtle Our customer care executives are available 24×7. diff --git a/app/utilities/http/rest.py b/app/utilities/http/rest.py index 63bfb25..0bf8855 100644 --- a/app/utilities/http/rest.py +++ b/app/utilities/http/rest.py @@ -2,7 +2,7 @@ from urllib import parse as urlparse -from torpedo.base_http_request import BaseHttpRequest +from app.service_clients.base_api_client import APIClient from app.service_clients.publisher import Publisher, PublishResult, OperatorDetails from app.constants import NotificationRequestLogStatus @@ -16,7 +16,7 @@ def __init__(self, host: str, endpoint: str, method: str): async def publish(self, payload: Dict[str, Any]) -> PublishResult: url = urlparse.urljoin(self._host, self._endpoint) - response = await BaseHttpRequest.request( + response = await APIClient.request( self._method, url, data=payload, @@ -35,8 +35,8 @@ async def publish(self, payload: Dict[str, Any]) -> PublishResult: else: publish_result = PublishResult( is_success=False, - status=NotificationRequestLogStatus.SUCCESS, - message=response.data.get("error", {}).get("message") + status=NotificationRequestLogStatus.FAILED, + message=response.data.get("message") or "Something went wrong", ) return publish_result diff --git a/app/utilities/parser.py b/app/utilities/parser.py index 4952be8..821dcea 100644 --- a/app/utilities/parser.py +++ b/app/utilities/parser.py @@ -1,5 +1,5 @@ from typing import Dict - +from .response import AsyncTaskResponse class V2PayloadParser: """ @@ -61,3 +61,30 @@ def _handle_v1_payload(cls, payload: Dict) -> Dict: "to": {"email": email, "mobile": mobile}, "email": {"subject": None, "cc": None, "bcc": None}, } + +class BaseHttpResponseParser: + def __init__(self, data, status_code, headers, response_headers_list): + self._data = data + self._status_code = status_code + self._headers = headers + self._response_headers_list = response_headers_list + + def parse(self) -> AsyncTaskResponse: + headers = self._prepare_headers() + return AsyncTaskResponse( + self._data, + meta=None, + status_code=self._status_code, + headers=headers, + ) + + def _prepare_headers(self): + final_headers = {} + if self._headers and self._response_headers_list: + for key in self._response_headers_list: + final_headers[key] = self._headers[key] + return final_headers + + return final_headers + + diff --git a/app/utilities/pubsub/sqs/sqs_wrapper.py b/app/utilities/pubsub/sqs/sqs_wrapper.py index d22297b..e760a70 100644 --- a/app/utilities/pubsub/sqs/sqs_wrapper.py +++ b/app/utilities/pubsub/sqs/sqs_wrapper.py @@ -60,7 +60,7 @@ async def publish(self, payload: dict, attributes=None, **kwargs): ) if response: publish_result = PublishResult( - is_success=True, status=NotificationRequestLogStatus.SUCCESS, + is_success=True, status=NotificationRequestLogStatus.INITIATED, message="Message successfully published to SQS" ) else: diff --git a/app/utilities/response.py b/app/utilities/response.py new file mode 100644 index 0000000..b41b521 --- /dev/null +++ b/app/utilities/response.py @@ -0,0 +1,31 @@ +class AsyncTaskResponse: + def __init__(self, data, meta=None, status_code=None, headers=None): + self._data = data + self._meta = meta + self._status_code = status_code + self._headers = headers + + @property + def data(self): + return self._data + + @property + def meta(self): + return self._meta + + @property + def status(self): + return self._status_code + + @property + def headers(self): + return self._headers + + def __dict__(self): + result = dict() + result["data"] = self._data + result["meta"] = self._meta + result["headers"] = self._headers + result["partial_complete"] = False + result["is_success"] = True + return result \ No newline at end of file diff --git a/app/utilities/url_shortener.py b/app/utilities/url_shortener.py new file mode 100644 index 0000000..7da6171 --- /dev/null +++ b/app/utilities/url_shortener.py @@ -0,0 +1,49 @@ +import logging +import time + +from commonutils.utils import Singleton +from torpedo import CONFIG +from torpedo.exceptions import HTTPRequestTimeoutException +from app.service_clients import LambdaClient + +logger = logging.getLogger() + + +class UrlShortenerCustom(metaclass=Singleton): + """ + class is used to shorten urls + """ + + def __init__(self): + self._start_time = int(time.time()) + + async def shorten_url(self, url: str): + _shortened_url = url + if not self.is_shortening_required(_shortened_url): + return _shortened_url + try: + _shortened_url = await self.lambda_shorten_url(url) + except HTTPRequestTimeoutException: + logger.info('Request to shorten url timed out. Client: {}, URL: {}'.format('lambda', url)) + except Exception as e: + logger.info('Error in shortening URL. Url: {}, Client: {}, Error: {}'.format(url, 'lambda', str(e))) + return _shortened_url + + @classmethod + def is_shortening_required(cls, url: str): + if not url: + return False + url_shorten_min_length = CONFIG.config['URL_SHORTENER']['SHORTABLE_URL_MIN_LENGTH'] or 30 + return len(url) >= url_shorten_min_length + + async def lambda_shorten_url(self, long_url: str): + response = await LambdaClient.shorten_url(long_url) + return self.extract_payload(response) + + @staticmethod + def extract_payload(response): + if response.status == 200: + return response.data['url'] + else: + error_msg = response.data.get('message') or 'Unknown error' + raise Exception(error_msg) diff --git a/app/utilities/utils.py b/app/utilities/utils.py index f261281..47cf400 100644 --- a/app/utilities/utils.py +++ b/app/utilities/utils.py @@ -14,7 +14,9 @@ from app.exceptions import RequiredParamsException from app.constants.constants import Event from .drivers import CustomJinjaEnvironment +from app.constants.constants import URL_SHORTENER_END_PATTERN, URL_SHORTENER_START_PATTERN, TRUNCATE_MAX_LEN, Event from app.constants.constants import Action, TriggerLimit +from app.models.notification_core import EventModel from app.constants import NotificationChannels def generate_uuid(): @@ -88,6 +90,7 @@ def current_epoch(): def is_notification_allowed_for_email(email: str) -> bool: test_allowed_emails = CONFIG.config.get('TEST_ALLOWED_EMAILS') or list() + print(f"Test allowed emails: {test_allowed_emails}") if not is_testing_environment(): return True @@ -120,6 +123,10 @@ def validate_required_params(payload: dict, required_params: list): raise RequiredParamsException( 'Following params: {} missing in payload'.format(",".join(missing_parms))) +def truncate(value, max_chars=TRUNCATE_MAX_LEN): + if len(value) > max_chars: + value = value[0 : max_chars - 5] + "....." + return value def get_event_unique_identifier(event_name: str, app_name: str) -> str: """ @@ -216,3 +223,17 @@ def is_valid_python_expression(expression: str) -> bool: return True except (SyntaxError, ValueError): return False + +def flatten_list(lis): + flattened = [] + for item in lis: + if isinstance(item, (list, tuple)): + flattened.extend(flatten_list(item)) + else: + flattened.append(item) + return flattened +def get_max(lis): + """ + Get the max of nested lists + """ + return max(flatten_list(lis)) diff --git a/config_template.json b/config_template.json index ab8c6d6..d24ab5b 100644 --- a/config_template.json +++ b/config_template.json @@ -93,7 +93,8 @@ "S3": { "AWS_REGION": "eu-west-2", "BUCKET_NAME": "1mg-staging-nscontentlogs", - "MAX_POOL_CONNECTIONS": 10 + "MAX_POOL_CONNECTIONS": 10, + "AWS_ENDPOINT_URL": "http://localhost:4566" } }, "NOTIFICATION_REQUEST": { @@ -130,13 +131,28 @@ "SENTRY": { "DSN": "" }, - "TEST_ENVIRONMENT": false, - "TEST_ALLOWED_EMAILS": [], + "TEST_ENVIRONMENT": true, + "TEST_ALLOWED_EMAILS": ["akash.bhat@1mg.com"], "TEST_ALLOWED_MOBILES": [], "PROVIDERS": { "ENCRYPTION": { "KEY": "####@@@@!!!!****", "AES_IV": "****@@@@!!!!####" } + }, + "AUTH": { + "HOST": "https://staginternalapi.1mg.com/auth", + "TIMEOUT": 10 + }, + "URL_SHORTENER": { + "SHORTABLE_URL_MIN_LENGTH": 30 + }, + "LAMBDA": { + "HOST": "", + "URL_SHORTENER_URI": "/dev/url-shortener-1mg-dev-store" + }, + "LARA": { + "HOST": "https://staginternalapi.1mg.com/lara_service", + "TIMEOUT": 10 } } \ No newline at end of file diff --git a/migrations/notification_core/10_20241106120300_set_default_callback_events.py b/migrations/notification_core/10_20241106120300_set_default_callback_events.py new file mode 100644 index 0000000..2ebafa4 --- /dev/null +++ b/migrations/notification_core/10_20241106120300_set_default_callback_events.py @@ -0,0 +1,15 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE apps + ALTER COLUMN callback_events + SET DEFAULT '{}'::jsonb; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE apps + ALTER COLUMN callback_events + DROP DEFAULT; + """ diff --git a/migrations/notification_core/11_20241106120400_add_variable_mapping_to_whatsapp.py b/migrations/notification_core/11_20241106120400_add_variable_mapping_to_whatsapp.py new file mode 100644 index 0000000..bc0164b --- /dev/null +++ b/migrations/notification_core/11_20241106120400_add_variable_mapping_to_whatsapp.py @@ -0,0 +1,12 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE whatsapp_content + ADD COLUMN IF NOT EXISTS variable_mapping JSONB DEFAULT '{}'::jsonb; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE whatsapp_content DROP COLUMN IF EXISTS variable_mapping; + """ diff --git a/migrations/notification_core/12_20241106120500_add_content_length.py b/migrations/notification_core/12_20241106120500_add_content_length.py new file mode 100644 index 0000000..14590fe --- /dev/null +++ b/migrations/notification_core/12_20241106120500_add_content_length.py @@ -0,0 +1,13 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE notification_request_log + ADD COLUMN IF NOT EXISTS content_length BIGINT NULL; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE notification_request_log + DROP COLUMN IF EXISTS content_length; + """ \ No newline at end of file diff --git a/migrations/notification_core/6_20241106115900_create_notification_request_log_status_enum.py b/migrations/notification_core/6_20241106115900_create_notification_request_log_status_enum.py new file mode 100644 index 0000000..60826ba --- /dev/null +++ b/migrations/notification_core/6_20241106115900_create_notification_request_log_status_enum.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DO $$ BEGIN + CREATE TYPE notification_request_log_status_enum AS ENUM ( + 'NEW', 'INITIATED', 'FAILED', 'SUCCESS' + ); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TYPE IF EXISTS notification_request_log_status_enum; + """ diff --git a/migrations/notification_core/7_20241106120000_add_notification_source_enum.py b/migrations/notification_core/7_20241106120000_add_notification_source_enum.py new file mode 100644 index 0000000..a6071a8 --- /dev/null +++ b/migrations/notification_core/7_20241106120000_add_notification_source_enum.py @@ -0,0 +1,19 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DO $$ BEGIN + CREATE TYPE notification_request_log_source_enum AS ENUM ('INTERNAL', 'WEBHOOK', 'UNKNOWN'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + ALTER TYPE notification_request_log_status_enum ADD VALUE IF NOT EXISTS 'QUEUED'; + ALTER TYPE notification_request_log_status_enum ADD VALUE IF NOT EXISTS 'PENDING'; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """"" + -- PostgreSQL does not support removing individual enum values easily + -- Downgrade would need to recreate the enum, which is typically skipped + """ \ No newline at end of file diff --git a/migrations/notification_core/8_20241106120100_add_source_and_channel_status_columns.py b/migrations/notification_core/8_20241106120100_add_source_and_channel_status_columns.py new file mode 100644 index 0000000..2877310 --- /dev/null +++ b/migrations/notification_core/8_20241106120100_add_source_and_channel_status_columns.py @@ -0,0 +1,24 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE notification_request_log + ADD COLUMN IF NOT EXISTS source notification_request_log_source_enum DEFAULT 'UNKNOWN'; + + ALTER TABLE notification_request_attempt + ADD COLUMN IF NOT EXISTS source notification_request_log_source_enum DEFAULT 'UNKNOWN'; + + ALTER TABLE notification_request_log + ADD COLUMN IF NOT EXISTS channel_status TEXT DEFAULT 'UNKNOWN'; + + ALTER TABLE notification_request_attempt + ADD COLUMN IF NOT EXISTS channel_status TEXT DEFAULT 'UNKNOWN'; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE notification_request_log DROP COLUMN IF EXISTS source; + ALTER TABLE notification_request_attempt DROP COLUMN IF EXISTS source; + ALTER TABLE notification_request_log DROP COLUMN IF EXISTS channel_status; + ALTER TABLE notification_request_attempt DROP COLUMN IF EXISTS channel_status; + """ diff --git a/migrations/notification_core/9_20241106120200_add_push_notification_type_enum.py b/migrations/notification_core/9_20241106120200_add_push_notification_type_enum.py new file mode 100644 index 0000000..417f6fc --- /dev/null +++ b/migrations/notification_core/9_20241106120200_add_push_notification_type_enum.py @@ -0,0 +1,22 @@ +from tortoise import BaseDBAsyncClient + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DO $$ BEGIN + CREATE TYPE push_notification_type_enum AS ENUM ('BANNER', 'CALL'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + + ALTER TABLE push_notification + ADD COLUMN IF NOT EXISTS type push_notification_type_enum DEFAULT 'BANNER'; + + ALTER TYPE push_notification_type_enum ADD VALUE IF NOT EXISTS 'LIVE_ACTIVITY'; + ALTER TYPE push_notification_type_enum ADD VALUE IF NOT EXISTS 'BACKGROUND'; + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE push_notification DROP COLUMN IF EXISTS type; + -- Dropping enum values not supported in Postgres easily; manual recreation needed + """ From b0e102b29944b4954b34bf0e7701d89745bdd988 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 2 Jul 2025 12:11:13 +0530 Subject: [PATCH 02/19] email core changes --- app/constants/providers.py | 4 +--- app/services/email.py | 31 ++++++++++++------------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/app/constants/providers.py b/app/constants/providers.py index bc51dc7..de8ff60 100644 --- a/app/constants/providers.py +++ b/app/constants/providers.py @@ -30,9 +30,7 @@ class Providers(CustomEnum): NotificationChannels.EMAIL.value ], "configuration": { - "API_KEY": "", - "AWS_ACCESS_KEY_ID": "", - "AWS_ACCESS_KEY_SECRET": "", + "API_KEY": "" } } diff --git a/app/services/email.py b/app/services/email.py index 50040cb..22ac11d 100644 --- a/app/services/email.py +++ b/app/services/email.py @@ -43,42 +43,35 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ status = NotificationRequestLogStatus.INITIATED message = None email_body = None - print('Handling email request: {}'.format(request_body)) - # 'request_id': '54530a6d-aef2-4dc3-9006-d80cb14d14ek', 'app_name': 'test_app', 'event_name': 'test_event', 'event_id': '2', 'source_identifier': 'REQ_1234', 'to': {'email': ['akash.bhat@1mg.com'], 'mobile': ['9915870128'], 'device': []}, 'channels': {'email': {'sender': {'name': 'Akash Bhat', 'address': 'akash.bhat@1mg.com'}}, 'whatsapp': {}}, 'body': {}} - reply_to = request_body.get('email', {}).get('reply_to') or request_body.get('reply_to', Email.REPLY_TO) try: send_address = NotificationChannels.get_sent_to_for_channel(NotificationChannels.EMAIL.value, request_body) if not send_address: raise NoSendAddressFoundException(ErrorMessages.NO_SEND_ADDRESS_FOUND.value) - print("Send address found: {}".format(send_address)) # Currently, we support only one email id in send_address send_address = send_address[0] if not is_notification_allowed_for_email(send_address): - print("Send address not allowed on test environment: {}".format(send_address)) raise NoSendAddressFoundException(ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value) + app = await AppsRepository.get_app_by_name(event.app_name) + if not app: + raise AppNotConfigured(ErrorMessages.APP_NOT_CONFIGURED.value) + email_subject, email_body = await cls.get_email_subject_and_body(event, request_body) - print("Email subject: {}".format(email_subject)) data = dispatch_notification_request_common_payload( event.id, event.event_name, event.app_name, NotificationChannels.EMAIL.value, notification_request_log_row.id ) + email_channel_data = request_body.get('channels', {}).get('email', {}) attachments = request_body.get('attachments') - cc = request_body.get('email').get('cc') + cc = email_channel_data.get('channels', {}).get('cc') - if cc and not is_notification_allowed_for_email(",".join(cc)): + if cc and not is_notification_allowed_for_email(",".join(cc[0])): raise NoSendAddressFoundException(ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value) - bcc = request_body.get('email').get('bcc') - email_channel_data = { - "cc": cc, - "bcc": bcc, - } - print("Publishing email with data: {}".format(data)) - result = await cls.publish(data, send_address, email_subject, email_body, reply_to, attachments, cc, bcc, event.priority) + result = await cls.publish(app, email_channel_data, data, send_address, email_subject, email_body, attachments, event.priority) except NoSendAddressFoundException as ne: status = NotificationRequestLogStatus.NOT_ELIGIBLE message = str(ne) @@ -112,13 +105,13 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ return result @classmethod - async def publish(cls, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): + async def publish(cls, app, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): cc = email_channel_data.get('cc') bcc = email_channel_data.get('bcc') sender = { - 'reply_to': email_channel_data.get('reply_to'), - 'name': email_channel_data.get('sender', {}).get('name'), - 'address': email_channel_data.get('sender', {}).get('address') + 'reply_to': email_channel_data.get('reply_to') or app.email.reply_to, + 'name': email_channel_data.get('sender', {}).get('name') or app.email.sender.name, + 'address': email_channel_data.get('sender', {}).get('address') or app.email.sender.address } data.update({ 'subject': subject, From 3aed40289528d1bb2401f3aaf60b706a50436839 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 2 Jul 2025 12:12:27 +0530 Subject: [PATCH 03/19] sms fix --- app/services/sms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/sms.py b/app/services/sms.py index fdf4331..6ff869d 100644 --- a/app/services/sms.py +++ b/app/services/sms.py @@ -45,7 +45,7 @@ async def handle( ErrorMessages.NO_SEND_ADDRESS_FOUND.value ) - if not is_notification_allowed_for_mobile(send_address): + if not is_notification_allowed_for_mobile(send_address[0]): raise NoSendAddressFoundException( ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value ) From 39436266cac0e2b6cba322b7c9fbee5946919212 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 14:24:34 +0530 Subject: [PATCH 04/19] docker file revert --- Dockerfile | 42 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/Dockerfile b/Dockerfile index b47414a..7561a01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,16 +8,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 # Turns off buffering for easier container logging ENV PYTHONUNBUFFERED=1 -# AWS specific env variables -### Set AWS_METADATA_SERVICE_TIMEOUT - set the timeout to 3 seconds -ENV AWS_METADATA_SERVICE_TIMEOUT=3 -### set AWS_METADATA_SERVICE_NUM_ATTEMPTS - set the max attempts to 3 -ENV AWS_METADATA_SERVICE_NUM_ATTEMPTS=3 - # Args passed in the build command ARG SERVICE_NAME -# Install system dependencies RUN apt-get update && \ apt-get install -y \ git \ @@ -28,42 +21,29 @@ RUN apt-get update && \ libssl-dev \ libffi-dev \ python3-dev \ - pkg-config \ - procps + pkg-config + +RUN echo "Y" | apt-get install procps -# Upgrade pip and install Python tools first -RUN pip install --upgrade pip setuptools wheel +RUN pip install --user pipenv +RUN pip install --upgrade pip # Install Rust RUN curl https://sh.rustup.rs -sSf | bash -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" -# Install pipenv after setuptools update -RUN pip install pipenv - -# Set up SSH for private repositories -RUN mkdir -p /root/.ssh -COPY .ssh/id_rsa /root/.ssh/id_rsa -COPY .ssh/known_hosts /root/.ssh/known_hosts -RUN chmod 600 /root/.ssh/id_rsa -RUN chmod 644 /root/.ssh/known_hosts - -# Add Bitbucket to known hosts and configure Git -RUN ssh-keyscan -t rsa bitbucket.org >> /root/.ssh/known_hosts -RUN git config --global core.sshCommand "ssh -o UserKnownHostsFile=/root/.ssh/known_hosts -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa" - -# Create app directory +# Create home ubuntu service RUN mkdir -p /home/ubuntu/apps/$SERVICE_NAME/logs + +# switch to code folder WORKDIR /home/ubuntu/apps/$SERVICE_NAME -RUN pip install aerich==0.7.2 premailer==3.10.0 pyjade==4.0.0 Jinja2==2.10.1 MarkupSafe==0.23 \ - sanic==22.12.0 sanic-openapi==21.12.0 -# Copy requirements files +# Copy and install requirements COPY Pipfile Pipfile.lock /home/ubuntu/apps/$SERVICE_NAME/ -RUN pipenv sync --system +RUN /root/.local/bin/pipenv sync --system # Copy code folder COPY . . -# Start the service +#Start the service CMD ["python3", "-m", "app.service"] \ No newline at end of file From 4c692d8037e6fe13e1784f4f88e3f0d986860cf5 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 14:25:46 +0530 Subject: [PATCH 05/19] updated to public repos --- Pipfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Pipfile b/Pipfile index 2cd03d8..0716180 100644 --- a/Pipfile +++ b/Pipfile @@ -4,10 +4,10 @@ verify_ssl = true name = "pypi" [packages] -torpedo = {editable = true, ref = "3.10.1", git = "ssh://git@bitbucket.org/tata1mg/torpedo.git"} -commonutils = {editable = true, ref = "1.8.3", git = "ssh://git@bitbucket.org/tata1mg/commonutils.git"} -tortoise_wrapper = {editable = true, ref = "0.7.0", git = "ssh://git@bitbucket.org/tata1mg/tortoise_wrapper.git"} -cache_wrapper = {editable = true, ref = "4.0.1", git = "ssh://git@bitbucket.org/tata1mg/cache_wrapper.git"} +torpedo = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/torpedo.git"} +commonutils = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/commonutils.git"} +tortoise_wrapper = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/tortoise_wrapper.git"} +cache_wrapper = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/cache_wrapper.git"} aerich = "0.7.2" premailer = "===3.10.0" pyjade = "===4.0.0" From 1cd583b091284c087107a214f319644e053574bc Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 14:28:10 +0530 Subject: [PATCH 06/19] changes to https --- Pipfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Pipfile b/Pipfile index 0716180..b4279cb 100644 --- a/Pipfile +++ b/Pipfile @@ -4,10 +4,10 @@ verify_ssl = true name = "pypi" [packages] -torpedo = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/torpedo.git"} -commonutils = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/commonutils.git"} -tortoise_wrapper = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/tortoise_wrapper.git"} -cache_wrapper = {editable = true, ref = "1.0.0", git = "git+ssh://git@github.com/tata1mg/cache_wrapper.git"} +torpedo = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/torpedo.git"} +commonutils = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/commonutils.git"} +tortoise_wrapper = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/tortoise_wrapper.git"} +cache_wrapper = {editable = true, ref = "1.0.0", git = "https://github.com/tata1mg/cache_wrapper.git"} aerich = "0.7.2" premailer = "===3.10.0" pyjade = "===4.0.0" From aeee546c70fd7f2b3804998f1a3cd89fa708d323 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 14:44:11 +0530 Subject: [PATCH 07/19] small change --- app/constants/constants.py | 2 +- app/constants/email.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/constants/constants.py b/app/constants/constants.py index 67c6684..1f2a65a 100644 --- a/app/constants/constants.py +++ b/app/constants/constants.py @@ -65,7 +65,7 @@ def _missing_(cls, _value): class SyncDispatcher: - ENDPOINT = "/v4/notify" + ENDPOINT = "/notify" METHOD = "POST" diff --git a/app/constants/email.py b/app/constants/email.py index a6b5534..46b43a9 100644 --- a/app/constants/email.py +++ b/app/constants/email.py @@ -1,5 +1,4 @@ class Email: - REPLY_TO = 'no-reply@mail.1mg.com' INCLUDE_START = '$include_start-' INCLUDE_END = '-$include_end' SUBJECT = 'subject' From 4adced7043ae45e8b1a037668a928e3ff7ed53bd Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 14:48:32 +0530 Subject: [PATCH 08/19] updated listener.py --- app/listeners/listener.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/listeners/listener.py b/app/listeners/listener.py index f2f0db2..57c0ab3 100644 --- a/app/listeners/listener.py +++ b/app/listeners/listener.py @@ -1,6 +1,5 @@ from torpedo import CONFIG from torpedo.constants import ListenerEventTypes -from redis_wrapper import cache_registry from asyncio import BaseEventLoop from app.repositories.content_log import ContentLogRepository from app.services import SubscribeNotificationRequest, SubscribeStatusUpdate @@ -37,10 +36,6 @@ async def setup_repositories(app, loop): config=CONFIG.config.get("CONTENT_LOG", {}).get("S3") ) -async def initialize_redis_caches(app): - cache_config = CONFIG.config["REDIS_CACHE_HOSTS"] - cache_registry.from_config(cache_config) - def exc_handler(self: BaseEventLoop, context: dict) -> None: """ @@ -65,11 +60,11 @@ async def setup_custom_exc_handler(app, loop): listeners = [ - (initialize_redis_caches, ListenerEventTypes.BEFORE_SERVER_START.value), (subscribe_for_notification_requests, ListenerEventTypes.AFTER_SERVER_START.value), (subscribe_for_status_updates, ListenerEventTypes.AFTER_SERVER_START.value), (setup_notification_channel_handlers, ListenerEventTypes.AFTER_SERVER_START.value), (initialize_jinja_environment, ListenerEventTypes.AFTER_SERVER_START.value), (setup_repositories, ListenerEventTypes.AFTER_SERVER_START.value), + (setup_options, ListenerEventTypes.BEFORE_SERVER_START.value), (setup_custom_exc_handler, ListenerEventTypes.BEFORE_SERVER_START.value), ] From 404b288278e1ffb3c8d8d88372577bca09d45453 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Wed, 9 Jul 2025 16:28:09 +0530 Subject: [PATCH 09/19] changes for public repo constraints --- app/constants/notification_channels.py | 3 +- app/routes/__init__.py | 25 ++++++------- app/routes/email.py | 26 +++++--------- app/routes/event.py | 24 +++++-------- app/routes/middleware/authentication.py | 47 ------------------------- app/routes/push.py | 14 ++------ app/routes/sms.py | 14 ++------ app/routes/whatsapp.py | 13 ++----- app/service.py | 3 +- 9 files changed, 40 insertions(+), 129 deletions(-) delete mode 100644 app/routes/middleware/authentication.py diff --git a/app/constants/notification_channels.py b/app/constants/notification_channels.py index bd0c041..0cb8c01 100644 --- a/app/constants/notification_channels.py +++ b/app/constants/notification_channels.py @@ -6,6 +6,7 @@ class NotificationChannels(CustomEnum): SMS = 'sms' WHATSAPP = 'whatsapp' PUSH = 'push' + CALL = 'call' def __str__(self) -> str: return self.value @@ -16,10 +17,8 @@ def get_sent_to_for_channel(cls, channel, request_body) -> list: send_address = list() if channel in [cls.EMAIL.value]: send_address = request_body.get('to', {}).get('email') or list() - send_address = [send_address[0]] if send_address else list() elif channel in [cls.SMS.value, cls.WHATSAPP.value]: send_address = request_body.get('to', {}).get('mobile') or list() - send_address = [send_address[0]] if send_address else list() elif channel in [cls.PUSH.value]: send_address = request_body.get('to', {}).get('device') or list() return send_address diff --git a/app/routes/__init__.py b/app/routes/__init__.py index f55d5dc..f1d13b9 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -3,31 +3,32 @@ from .apps import apps_blueprint from .prepare_notification import notify_bp from .notification_status import notification_status_blueprint -from .event import event_blueprint, event_with_auth, internal_event_blueprint -from .email import email_api_with_auth, email_blueprint -from .push import push_apis_with_auth -from .sms import sms_apis_with_auth -from .whatsapp import whatsapp_apis_with_auth +from .event import event_blueprint +from .email import email_blueprint +from .push import push_apis +from .sms import sms_apis +from .whatsapp import whatsapp_apis from .form.structure import form_bp from .dashboard.providers import providers_blueprint from .dashboard.home_page import homepage_blueprint from .dashboard.settings import settings_blueprint from .channel_partners import channel_partners_apis +from app.routes.dashboard.activity_feed import activity_feed_blueprint + + blueprint_group = Blueprint.group( notification_status_blueprint, event_blueprint, - event_with_auth, - internal_event_blueprint, notify_bp, - email_api_with_auth, email_blueprint, - push_apis_with_auth, - sms_apis_with_auth, - whatsapp_apis_with_auth, + push_apis, + sms_apis, + whatsapp_apis, apps_blueprint, form_bp, providers_blueprint, homepage_blueprint, settings_blueprint, - channel_partners_apis + channel_partners_apis, + activity_feed_blueprint ) diff --git a/app/routes/email.py b/app/routes/email.py index 50a9ac7..36231c4 100644 --- a/app/routes/email.py +++ b/app/routes/email.py @@ -2,20 +2,10 @@ from sanic import Blueprint from app.manager.email_manager import EmailManager from app.exceptions import RequiredParamsException -from app.routes.middleware.authentication import HttpRequestAuthentication -email_blueprint = Blueprint("email", version=4) -email_api_with_auth = Blueprint("EmailAPIs") +email_blueprint = Blueprint("EmailAPIs") -@email_api_with_auth.on_request -async def request_authenticator(request: Request): - """ - Middleware to authenticate each incoming request of create event - """ - await HttpRequestAuthentication.examine_request(request) - - -@email_api_with_auth.route("/email/template", methods=["PUT"], name="update_email_template") +@email_blueprint.route("/email/template", methods=["PUT"], name="update_email_template") async def update_email_template(request: Request): payload = request.custom_json() user_email = "admin@ns.com" @@ -30,7 +20,7 @@ async def update_email_template(request: Request): ) return send_response(result) -@email_api_with_auth.route( +@email_blueprint.route( "/email/template/preview", methods=["POST"], name="preview email_template" ) async def get_email_template_previews(request: Request): @@ -49,7 +39,7 @@ async def get_email_template_previews(request: Request): ) return send_response(result) -@email_api_with_auth.route( +@email_blueprint.route( "/email/include/template", methods=["POST"], name="create_include_email_template" ) async def create_include_email_template(request: Request): @@ -73,10 +63,10 @@ async def get_email_template(request: Request): data = await EmailManager.get_email_template_by_id(template_id) return send_response(data=data) -@email_api_with_auth.route("/email/subtemplate/", methods=["PUT"], name="update_email_subtemplate") +@email_blueprint.route("/email/subtemplate/", methods=["PUT"], name="update_email_subtemplate") async def update_email_subtemplate(request: Request, id: int): payload = request.custom_json() - user_email = request.ctx.user + user_email = "admin@ns.com" content, description = ( payload.get("content"), payload.get("description"), @@ -84,7 +74,7 @@ async def update_email_subtemplate(request: Request, id: int): result = await EmailManager.update_email_subtemplate(int(id), content, description, user_email) return send_response(result) -@email_api_with_auth.route("/email/preview", methods=["POST"], name="preview_email_subtemplate") +@email_blueprint.route("/email/preview", methods=["POST"], name="preview_email_subtemplate") async def preview_email_subtemplate(request: Request): request_params = request.request_params() payload = request.custom_json() @@ -93,7 +83,7 @@ async def preview_email_subtemplate(request: Request): template_id = payload.get("id") description = payload.get("description") content = payload.get("content") - user_email = request.ctx.user + user_email = "admin@ns.com" result = await EmailManager.preview_email_subtemplate(template_id, description, content, user_email, int(start), int(size)) return send_response(result) diff --git a/app/routes/event.py b/app/routes/event.py index 826b214..a1993f9 100644 --- a/app/routes/event.py +++ b/app/routes/event.py @@ -7,16 +7,8 @@ from app.services.email import EmailHandler from app.utilities import current_epoch from app.routes.api_models.event import CreateEventApiModel -from app.routes.middleware.authentication import HttpRequestAuthentication event_blueprint = Blueprint("Events") -event_with_auth = Blueprint("event_with_auth", version=4) -internal_event_blueprint = InternalApiBlueprint("event_internal") - -@event_with_auth.on_request -async def request_authenticator(request: Request): - # Middleware to authenticate each incoming request of create event - await HttpRequestAuthentication.examine_request(request) @event_blueprint.route("/events/custom", methods=["GET"], name="get_events_custom") async def get_events_custom(request: Request): @@ -39,7 +31,7 @@ async def handle_email_template_update(request: Request): data = {"message": "success"} return send_response(data=data) -@event_with_auth.route(CreateEventApiModel.uri(), methods=[CreateEventApiModel.http_method()], name=CreateEventApiModel.name()) +@event_blueprint.route(CreateEventApiModel.uri(), methods=[CreateEventApiModel.http_method()], name=CreateEventApiModel.name()) @openapi.definition( summary=CreateEventApiModel.summary(), description=CreateEventApiModel.description(), @@ -56,10 +48,10 @@ async def create_event(request: Request): result = await Events.create_event(data) return send_response(result) -@event_with_auth.route("/event/add_action", methods=["PUT"], name="update_event") +@event_blueprint.route("/event/add_action", methods=["PUT"], name="update_event") async def update_event(request: Request): data = request.custom_json() - data["user_email"] = request.ctx.user + data["user_email"] = "temp@ns.com" result = await Events.add_new_action_to_event(data) return send_response(result) @@ -77,18 +69,18 @@ async def get_events(request: Request): result = await Events.get_events(request.args) return send_response(result) -@event_with_auth.route( +@event_blueprint.route( "/event/", methods=["DELETE"], name="delete_event_with_id" ) async def delete_event(request: Request, event_id: int): - user_email = request.ctx.user + user_email = "temp@ns.com" result = await Events.delete_event(event_id, user_email) return send_response(result) -@event_with_auth.route("/event/", methods=["PUT"], name="update_event") +@event_blueprint.route("/event/", methods=["PUT"], name="update_event") async def update_event(request: Request, event_id: int): data = request.custom_json() - data["user_email"] = request.ctx.user + data["user_email"] = "temp@ns.com" if event_id is None: raise BadRequestException("event id is missing in the url") result = await Events.update_event_data(event_id, data) @@ -112,7 +104,7 @@ async def get_content_for_event(request: Request): return send_response(data=result) -@internal_event_blueprint.route( +@event_blueprint.route( "/v4/event/content", methods=["POST"], name="get_content_for_event", diff --git a/app/routes/middleware/authentication.py b/app/routes/middleware/authentication.py deleted file mode 100644 index 60d808b..0000000 --- a/app/routes/middleware/authentication.py +++ /dev/null @@ -1,47 +0,0 @@ -from app.exceptions import ( - UnAuthorizeException, - ForbiddenException, - ResourceNotFoundException -) -from app.service_clients.auth.auth import AuthClient -from torpedo.exceptions import BadRequestException - -class HttpRequestAuthentication: - APP_NAME = "lara" - LARA_APP = "LARA_APP" - roles = ["LARA_ADMIN"] - - @classmethod - async def examine_request(cls, request, *args): - auth_token = request.headers.get("Authorization", None) - await cls.validate_authorization( - auth_token=auth_token, request=request, roles=cls.roles - ) - - @classmethod - async def validate_authorization(cls, auth_token, request, roles): - if auth_token is None: - raise BadRequestException("auth token is required to perform action") - try: - user_result = await AuthClient.authenticate(auth_token=auth_token) - except ResourceNotFoundException: - raise ForbiddenException( - "authorization failed: invalid or expired authentication token" - ) - cls.validate_roles(user_object=user_result, roles=roles) - request.ctx.user = user_result.get("email") - - @classmethod - def validate_roles(cls, user_object, roles): - if user_object is None or not len( - [app for app in [cls.APP_NAME, cls.LARA_APP] if app in user_object["roles"]] - ): - raise UnAuthorizeException("user is not authorized") - authenticated = False - for app in [cls.APP_NAME, cls.LARA_APP]: - for user_role in user_object["roles"].get(app, []): - if user_role in roles: - authenticated = True - if not authenticated: - raise ForbiddenException("user is not permitted") - \ No newline at end of file diff --git a/app/routes/push.py b/app/routes/push.py index f3cf261..963f724 100644 --- a/app/routes/push.py +++ b/app/routes/push.py @@ -2,18 +2,10 @@ from sanic import Blueprint from app.manager.push_notification_manager import PushManager from app.exceptions import RequiredParamsException -from app.routes.middleware.authentication import HttpRequestAuthentication -push_apis_with_auth = Blueprint("PushAPIs") +push_apis = Blueprint("PushAPIs") -@push_apis_with_auth.on_request -async def request_authenticator(request: Request): - """ - Middleware to authenticate each incoming request of create event - """ - await HttpRequestAuthentication.examine_request(request) - -@push_apis_with_auth.route("/push/template", methods=["PUT"], name="update_push_template") +@push_apis.route("/push/template", methods=["PUT"], name="update_push_template") async def update_push_template(request: Request): payload = request.custom_json() user_email = "temp@ns.com" @@ -29,7 +21,7 @@ async def update_push_template(request: Request): message = {"message": "push notification has been updated sucessfully"} return send_response(message) -@push_apis_with_auth.route( +@push_apis.route( "/push/template/preview", methods=["POST"], name="preview_push_template" ) async def get_push_template_previews(request: Request): diff --git a/app/routes/sms.py b/app/routes/sms.py index 9390230..4240f46 100644 --- a/app/routes/sms.py +++ b/app/routes/sms.py @@ -2,18 +2,10 @@ from sanic import Blueprint from app.manager.sms_manager import SmsManager from app.exceptions import RequiredParamsException -from app.routes.middleware.authentication import HttpRequestAuthentication -sms_apis_with_auth = Blueprint("SmsAPIs") +sms_apis = Blueprint("SmsAPIs") -@sms_apis_with_auth.on_request -async def request_authenticator(request: Request): - """ - Middleware to authenticate each incoming request of create event - """ - await HttpRequestAuthentication.examine_request(request) - -@sms_apis_with_auth.route("/sms/template", methods=["PUT"], name="update_sms_template") +@sms_apis.route("/sms/template", methods=["PUT"], name="update_sms_template") async def update_sms_template(request: Request): payload = request.custom_json() user_email = "temp@ns.com" @@ -29,7 +21,7 @@ async def update_sms_template(request: Request): message = {"message": "sms template has been updated sucessfully"} return send_response(message) -@sms_apis_with_auth.route( +@sms_apis.route( "/sms/template/preview", methods=["POST"], name="preview_sms_template" ) async def get_sms_template_previews(request: Request): diff --git a/app/routes/whatsapp.py b/app/routes/whatsapp.py index 3a03922..e109fcd 100644 --- a/app/routes/whatsapp.py +++ b/app/routes/whatsapp.py @@ -2,19 +2,10 @@ from sanic import Blueprint from app.manager.whatsapp_manager import WhatsappManager from app.exceptions import RequiredParamsException -from app.routes.middleware.authentication import HttpRequestAuthentication -whatsapp_apis_with_auth = Blueprint("WhatsappAPIs") +whatsapp_apis = Blueprint("WhatsappAPIs") -@whatsapp_apis_with_auth.on_request -async def request_authenticator(request: Request): - """ - Middleware to authenticate each incoming request of create event - """ - await HttpRequestAuthentication.examine_request(request) - - -@whatsapp_apis_with_auth.route( +@whatsapp_apis.route( "/whatsapp/template", methods=["PUT"], name="update_whatsapp_template" ) async def update_whatsapp_template(request: Request): diff --git a/app/service.py b/app/service.py index 2a2008a..0caac28 100644 --- a/app/service.py +++ b/app/service.py @@ -1,12 +1,13 @@ from torpedo import Host, CONFIG - +from redis_wrapper import RegisterRedis from app.listeners import listeners from app.middlewares import custom_response_middlewares from app.routes import blueprint_group if __name__ == "__main__": + RegisterRedis.register_redis_cache(CONFIG.config["REDIS_CACHE_HOSTS"]) # Register listeners Host._listeners = listeners From 5715955c7e2c081cd3643ad2fc743850d3da84ef Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Thu, 10 Jul 2025 16:09:37 +0530 Subject: [PATCH 10/19] removed tokens --- app/constants/notification_channels.py | 1 - app/constants/providers.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/constants/notification_channels.py b/app/constants/notification_channels.py index 0cb8c01..b04c23a 100644 --- a/app/constants/notification_channels.py +++ b/app/constants/notification_channels.py @@ -6,7 +6,6 @@ class NotificationChannels(CustomEnum): SMS = 'sms' WHATSAPP = 'whatsapp' PUSH = 'push' - CALL = 'call' def __str__(self) -> str: return self.value diff --git a/app/constants/providers.py b/app/constants/providers.py index de8ff60..c098bfa 100644 --- a/app/constants/providers.py +++ b/app/constants/providers.py @@ -149,10 +149,10 @@ class Providers(CustomEnum): "verification": "PHARMACY" }, "AUTHORIZATION": { - "CORPORATE": "Basic VkN6SjkxZVQ3SkV6R2sxdmtMblZkb092M1dHUVR2RXZ2ekVUVlJBNHZsTTo=", - "DEFAULT": "Basic dm9VOWNseVNPeUpadUpmQ2VTMnVfdFhqdzB4V3JvWVNvUWozOVpIM2NGNDo=", - "DIAGNOSTICS": "Basic dm9VOWNseVNPeUpadUpmQ2VTMnVfdFhqdzB4V3JvWVNvUWozOVpIM2NGNDo=", - "PHARMACY": "Basic TTl1SFg2TlZxOWJjZDNndldsSGhfRVBWRVd0ZWNRbHVVS2ZOeV8temllZzo=" + "CORPORATE": "", + "DEFAULT": "", + "DIAGNOSTICS": "", + "PHARMACY": "" }, "HOST": "https://api.interakt.ai", "PATH": "/v1/public/message/" From 57df4a37ef8ebde27f35031ac0c57fad6752e9c4 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Thu, 10 Jul 2025 16:41:56 +0530 Subject: [PATCH 11/19] removed print statements --- app/routes/dashboard/providers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/routes/dashboard/providers.py b/app/routes/dashboard/providers.py index 128524a..ee70e0f 100644 --- a/app/routes/dashboard/providers.py +++ b/app/routes/dashboard/providers.py @@ -8,10 +8,8 @@ @providers_blueprint.route("", methods=["POST"], name="add_new_provider") async def add_new_provider(request: Request): - print(f"Received request: {request.custom_json()}") data = request.custom_json() resp = await DashboardProvidersScreen.add_new_provider(data) - print("Response from add_new_provider:", resp) return send_response(resp) From a3a45b8a7501b6ba4395e59ae9f351325695c3b6 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Thu, 10 Jul 2025 16:58:21 +0530 Subject: [PATCH 12/19] typo fix --- app/routes/form/structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/form/structure.py b/app/routes/form/structure.py index 831a83c..57f1d8d 100644 --- a/app/routes/form/structure.py +++ b/app/routes/form/structure.py @@ -43,7 +43,7 @@ async def get_add_provider_form(_req, channel, provider): async def get_update_provider_form(_req, unique_identifier): if not unique_identifier: raise BadRequestException("Missing unique_identifier") - return json(body=await UpdateProviderForm().get_instance_asdict(unique_identifier)) + return json(body=await UpdateProviderForm.get_instance_asdict(unique_identifier)) @form_bp.get("/update-event/") async def get_update_event_form(_req, event_id: int): From be6c18246551e50f1e89b5c792fbc54bf9f029c3 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Tue, 15 Jul 2025 10:03:20 +0530 Subject: [PATCH 13/19] removed auth client --- app/service_clients/__init__.py | 3 +-- app/service_clients/auth/__init__.py | 2 -- app/service_clients/auth/auth.py | 36 ---------------------------- 3 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 app/service_clients/auth/__init__.py delete mode 100644 app/service_clients/auth/auth.py diff --git a/app/service_clients/__init__.py b/app/service_clients/__init__.py index b391829..5f93fa8 100644 --- a/app/service_clients/__init__.py +++ b/app/service_clients/__init__.py @@ -1,4 +1,3 @@ -__all__ = ["AuthClient","LambdaClient"] +__all__ = ["LambdaClient"] -from .auth import AuthClient from .aws_lambda import LambdaClient diff --git a/app/service_clients/auth/__init__.py b/app/service_clients/auth/__init__.py deleted file mode 100644 index 901eff4..0000000 --- a/app/service_clients/auth/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__=["AuthClient"] -from .auth import AuthClient diff --git a/app/service_clients/auth/auth.py b/app/service_clients/auth/auth.py deleted file mode 100644 index fa6343a..0000000 --- a/app/service_clients/auth/auth.py +++ /dev/null @@ -1,36 +0,0 @@ -from torpedo import CONFIG -from torpedo.parser import BaseApiResponseParser -from ..base_api_client import APIClient - - -class AuthClient(APIClient): - """ - auth service client - """ - auth_config = CONFIG.config['AUTH'] - _host = auth_config['HOST'] - _timeout = auth_config['TIMEOUT'] - _parser = BaseApiResponseParser - - @classmethod - async def get_devices_by_email_id(cls, email_id: str): - path = '/v6/devices' - payload = { - 'email': email_id - } - devices = await cls.post(path, data=payload) - return devices.data - - @classmethod - async def authenticate(cls, auth_token): - """ - This is used for authenticating given auth by calling identity service. - :param auth_token: auth token which will be authenticated. - :return: dict of response from identity service. - """ - path = '/v6/authenticate' - payload = { - 'authentication_token': auth_token - } - response = await cls.post(path, data=payload) - return response.data \ No newline at end of file From cfef1b040653cf963d3b41e68d0f70b3df87d36f Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Tue, 15 Jul 2025 10:23:36 +0530 Subject: [PATCH 14/19] middleware update --- app/middlewares/response_middleware.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/app/middlewares/response_middleware.py b/app/middlewares/response_middleware.py index d896702..5da001e 100644 --- a/app/middlewares/response_middleware.py +++ b/app/middlewares/response_middleware.py @@ -19,17 +19,7 @@ def _add_cors_headers(response, methods: Iterable[str]) -> None: async def add_cors_headers(request, response): - print(f"Request incoming: {request.method} {request.path} {request.route}") - if request.method == "OPTIONS": - # For OPTIONS requests, we need to add CORS headers - # We'll use a default set of methods since there's no route.methods for OPTIONS - methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] - _add_cors_headers(response, methods) - elif hasattr(request, 'route') and request.route is not None: - # For non-OPTIONS requests with valid routes + if request.method != "OPTIONS": methods = [method for method in request.route.methods] _add_cors_headers(response, methods) - else: - # For error cases where route might be None - methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] - _add_cors_headers(response, methods) \ No newline at end of file + \ No newline at end of file From 52b6339192c8f1c690a308784e28d503fbb11e8a Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 21 Jul 2025 12:24:51 +0530 Subject: [PATCH 15/19] updated for handling multiple emails --- app/services/email.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/services/email.py b/app/services/email.py index 22ac11d..274e66e 100644 --- a/app/services/email.py +++ b/app/services/email.py @@ -49,17 +49,20 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ if not send_address: raise NoSendAddressFoundException(ErrorMessages.NO_SEND_ADDRESS_FOUND.value) - # Currently, we support only one email id in send_address - send_address = send_address[0] - - if not is_notification_allowed_for_email(send_address): - raise NoSendAddressFoundException(ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value) + allowed_addresses = [] + + for email in send_address: + if is_notification_allowed_for_email(email): + allowed_addresses.append(email) + + send_address = allowed_addresses app = await AppsRepository.get_app_by_name(event.app_name) if not app: raise AppNotConfigured(ErrorMessages.APP_NOT_CONFIGURED.value) email_subject, email_body = await cls.get_email_subject_and_body(event, request_body) + data = dispatch_notification_request_common_payload( event.id, event.event_name, event.app_name, NotificationChannels.EMAIL.value, notification_request_log_row.id @@ -105,7 +108,7 @@ async def handle(cls, event: EventModel, request_body, notification_request_log_ return result @classmethod - async def publish(cls, app, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): + async def publish(cls, app: AppsModel, email_channel_data, data, recipient, subject, body, attachments=None, priority:EventPriority = EventPriority.LOW): cc = email_channel_data.get('cc') bcc = email_channel_data.get('bcc') sender = { @@ -126,7 +129,6 @@ async def publish(cls, app, email_channel_data, data, recipient, subject, body, @classmethod async def get_email_subject_and_body(cls, event: EventModel, request_body: dict): - print("Getting email subject and body for event: {}".format(event.event_name)) email_body, raw_subject = await cls.get_email_details(event, request_body['body']) email_channel_data = request_body["channels"]["email"] subject = email_channel_data.get('subject') or raw_subject or '' @@ -137,6 +139,7 @@ async def get_email_subject_and_body(cls, event: EventModel, request_body: dict) async def get_email_details(cls, event: EventModel, data: dict): email_content = await EmailContentRepository.get_email_content_from_event_id(event.id) # Future Scope : Handle support for Multiple templates + if email_content: email_body, subject = await cls.get_email_details_from_db(data, email_content) else: From e3e52cff68429808d81beabfcde5bcc69e098691 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 21 Jul 2025 12:29:04 +0530 Subject: [PATCH 16/19] updated config template --- config_template.json | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/config_template.json b/config_template.json index d24ab5b..ab8c6d6 100644 --- a/config_template.json +++ b/config_template.json @@ -93,8 +93,7 @@ "S3": { "AWS_REGION": "eu-west-2", "BUCKET_NAME": "1mg-staging-nscontentlogs", - "MAX_POOL_CONNECTIONS": 10, - "AWS_ENDPOINT_URL": "http://localhost:4566" + "MAX_POOL_CONNECTIONS": 10 } }, "NOTIFICATION_REQUEST": { @@ -131,28 +130,13 @@ "SENTRY": { "DSN": "" }, - "TEST_ENVIRONMENT": true, - "TEST_ALLOWED_EMAILS": ["akash.bhat@1mg.com"], + "TEST_ENVIRONMENT": false, + "TEST_ALLOWED_EMAILS": [], "TEST_ALLOWED_MOBILES": [], "PROVIDERS": { "ENCRYPTION": { "KEY": "####@@@@!!!!****", "AES_IV": "****@@@@!!!!####" } - }, - "AUTH": { - "HOST": "https://staginternalapi.1mg.com/auth", - "TIMEOUT": 10 - }, - "URL_SHORTENER": { - "SHORTABLE_URL_MIN_LENGTH": 30 - }, - "LAMBDA": { - "HOST": "", - "URL_SHORTENER_URI": "/dev/url-shortener-1mg-dev-store" - }, - "LARA": { - "HOST": "https://staginternalapi.1mg.com/lara_service", - "TIMEOUT": 10 } } \ No newline at end of file From bd25c477f5fc1de6fac6f1fd009cce0bf8b76493 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 21 Jul 2025 12:37:32 +0530 Subject: [PATCH 17/19] removed print statements --- app/utilities/aws/s3.py | 2 -- app/utilities/utils.py | 1 - 2 files changed, 3 deletions(-) diff --git a/app/utilities/aws/s3.py b/app/utilities/aws/s3.py index 8f30f60..1ff8d93 100644 --- a/app/utilities/aws/s3.py +++ b/app/utilities/aws/s3.py @@ -37,9 +37,7 @@ async def upload(self, bucket: str, content: str, key: str): """Upload a file to an S3 bucket""" # Upload the file try: - print("Uploading file %s to Bucket %s", key, bucket) client = await self.get_s3_client() - print("S3 Client: %s", client) response = await client.put_object(Body=content, Bucket=bucket, Key=key) except Exception as err: logger.error("Error uploading file %s to Bucket %s %s", key, bucket, err) diff --git a/app/utilities/utils.py b/app/utilities/utils.py index 47cf400..7047362 100644 --- a/app/utilities/utils.py +++ b/app/utilities/utils.py @@ -90,7 +90,6 @@ def current_epoch(): def is_notification_allowed_for_email(email: str) -> bool: test_allowed_emails = CONFIG.config.get('TEST_ALLOWED_EMAILS') or list() - print(f"Test allowed emails: {test_allowed_emails}") if not is_testing_environment(): return True From a5f85952f204b531d20884079d5ba1a69c3bba78 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 21 Jul 2025 12:58:38 +0530 Subject: [PATCH 18/19] removed url shortener as auth client is not available --- app/service_clients/aws_lambda/__init__.py | 2 - app/service_clients/aws_lambda/aws_lambda.py | 19 -------- app/utilities/url_shortener.py | 49 -------------------- 3 files changed, 70 deletions(-) delete mode 100644 app/service_clients/aws_lambda/__init__.py delete mode 100644 app/service_clients/aws_lambda/aws_lambda.py delete mode 100644 app/utilities/url_shortener.py diff --git a/app/service_clients/aws_lambda/__init__.py b/app/service_clients/aws_lambda/__init__.py deleted file mode 100644 index dcf4bc2..0000000 --- a/app/service_clients/aws_lambda/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__=["LambdaClient"] -from .aws_lambda import LambdaClient \ No newline at end of file diff --git a/app/service_clients/aws_lambda/aws_lambda.py b/app/service_clients/aws_lambda/aws_lambda.py deleted file mode 100644 index 73aac2a..0000000 --- a/app/service_clients/aws_lambda/aws_lambda.py +++ /dev/null @@ -1,19 +0,0 @@ -from torpedo import CONFIG -from ..base_api_client import APIClient - -class LambdaClient(APIClient): - """ - auth service client - """ - _lambda_config = CONFIG.config['LAMBDA'] - _host = _lambda_config['HOST'] - _timeout = 10 - - @classmethod - async def shorten_url(cls, long_url ): - request_uri = cls._lambda_config['URL_SHORTENER_URI'] - auth = cls._lambda_config['AUTH'] - data = {"auth": auth, "url": long_url} - response = await cls.post(path=request_uri, data=data, headers={'Content-Type': 'application/json'}) - return response - \ No newline at end of file diff --git a/app/utilities/url_shortener.py b/app/utilities/url_shortener.py deleted file mode 100644 index 7da6171..0000000 --- a/app/utilities/url_shortener.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging -import time - -from commonutils.utils import Singleton -from torpedo import CONFIG -from torpedo.exceptions import HTTPRequestTimeoutException -from app.service_clients import LambdaClient - -logger = logging.getLogger() - - -class UrlShortenerCustom(metaclass=Singleton): - """ - class is used to shorten urls - """ - - def __init__(self): - self._start_time = int(time.time()) - - async def shorten_url(self, url: str): - _shortened_url = url - if not self.is_shortening_required(_shortened_url): - return _shortened_url - try: - _shortened_url = await self.lambda_shorten_url(url) - except HTTPRequestTimeoutException: - logger.info('Request to shorten url timed out. Client: {}, URL: {}'.format('lambda', url)) - except Exception as e: - logger.info('Error in shortening URL. Url: {}, Client: {}, Error: {}'.format(url, 'lambda', str(e))) - return _shortened_url - - @classmethod - def is_shortening_required(cls, url: str): - if not url: - return False - url_shorten_min_length = CONFIG.config['URL_SHORTENER']['SHORTABLE_URL_MIN_LENGTH'] or 30 - return len(url) >= url_shorten_min_length - - async def lambda_shorten_url(self, long_url: str): - response = await LambdaClient.shorten_url(long_url) - return self.extract_payload(response) - - @staticmethod - def extract_payload(response): - if response.status == 200: - return response.data['url'] - else: - error_msg = response.data.get('message') or 'Unknown error' - raise Exception(error_msg) From 259adcda6a9b86b3ef8c2557deac8b223fb1f0a4 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 21 Jul 2025 13:55:34 +0530 Subject: [PATCH 19/19] small fixes --- app/constants/__init__.py | 3 ++- app/constants/constants.py | 18 --------------- app/service_clients/__init__.py | 3 --- app/services/dashboard/activity_feed.py | 1 - app/services/event.py | 3 ++- .../form/forms/create_event/parent.py | 2 +- app/services/push.py | 23 ------------------- 7 files changed, 5 insertions(+), 48 deletions(-) diff --git a/app/constants/__init__.py b/app/constants/__init__.py index 59807a3..61dfdd6 100644 --- a/app/constants/__init__.py +++ b/app/constants/__init__.py @@ -1,7 +1,8 @@ -from .constants import SyncDispatcher, EventType +from .constants import SyncDispatcher from .error_messages import ErrorMessages from .notification_channels import NotificationChannels from .event_priority import EventPriority +from .event import EventType from .database_tables import DatabaseTables from .notification_request_log_status import NotificationRequestLogStatus from .email import Email diff --git a/app/constants/constants.py b/app/constants/constants.py index 1f2a65a..8648e75 100644 --- a/app/constants/constants.py +++ b/app/constants/constants.py @@ -46,24 +46,6 @@ class Event: DEFAULT_OFFSET = 0 ID = "id" - -class EventType(Enum): - PROMOTIONAL = "promotional" - TRANSACTIONAL = "transactional" - OTHER = "other" - - -class EventPriority(CustomEnum): - CRITICAL = "critical" - HIGH = "high" - MEDIUM = "medium" - LOW = "low" - - @classmethod - def _missing_(cls, _value): - return cls.LOW - - class SyncDispatcher: ENDPOINT = "/notify" METHOD = "POST" diff --git a/app/service_clients/__init__.py b/app/service_clients/__init__.py index 5f93fa8..e69de29 100644 --- a/app/service_clients/__init__.py +++ b/app/service_clients/__init__.py @@ -1,3 +0,0 @@ -__all__ = ["LambdaClient"] - -from .aws_lambda import LambdaClient diff --git a/app/services/dashboard/activity_feed.py b/app/services/dashboard/activity_feed.py index 889d46f..fc15266 100644 --- a/app/services/dashboard/activity_feed.py +++ b/app/services/dashboard/activity_feed.py @@ -1,4 +1,3 @@ -from pydantic.main import validate_model from torpedo.exceptions import BadRequestException from app.constants import NotificationChannels, NotificationRequestLogStatus diff --git a/app/services/event.py b/app/services/event.py index 006c865..502af66 100644 --- a/app/services/event.py +++ b/app/services/event.py @@ -10,7 +10,8 @@ from app.models.notification_core import EventModel from app.repositories.event import EventRepository from app.constants import NotificationChannels -from app.constants.constants import Event, EventType, Action +from app.constants.constants import Event, Action +from app.constants.event import EventType from app.constants.email import Email from app.constants.event_priority import EventPriority from app.exceptions import InvalidParamsException, ResourceConflictException diff --git a/app/services/form/forms/create_event/parent.py b/app/services/form/forms/create_event/parent.py index f3e9422..d44c4a3 100644 --- a/app/services/form/forms/create_event/parent.py +++ b/app/services/form/forms/create_event/parent.py @@ -1,4 +1,4 @@ -from app.constants import EventPriority, EventType +from app.constants.event import EventPriority, EventType from app.services.apps import AppService from app.services.form.fields import ( Collection, diff --git a/app/services/push.py b/app/services/push.py index ec2bd12..2884440 100644 --- a/app/services/push.py +++ b/app/services/push.py @@ -12,11 +12,9 @@ from app.models.notification_core import EventModel from app.repositories.push_notification import PushNotificationRepository from app.service_clients.publisher import PublishResult -from app.service_clients import AuthClient from app.utilities import ( render_text, dispatch_notification_request_common_payload, - is_notification_allowed_for_email, ) from .abstract_handler import AbstractHandler from .logging import NotificationRequestLog @@ -129,27 +127,6 @@ async def get_devices(cls, request_body): if devices: return devices - send_address = NotificationChannels.get_sent_to_for_channel( - NotificationChannels.PUSH.value, request_body - ) - if not send_address: - raise NoSendAddressFoundException(ErrorMessages.NO_SEND_ADDRESS_FOUND.value) - - if not is_notification_allowed_for_email(send_address): - raise NoSendAddressFoundException( - ErrorMessages.SEND_ADDRESS_NOT_ALLOWED_ON_TEST_ENV.value - ) - - # The devices list received from auth is sorted on created data. - devices = await AuthClient.get_devices_by_email_id(send_address) - if devices and not devices["devices"]: - raise ResourceNotFoundException( - ErrorMessages.NO_REGISTERED_DEVICE_FOUND.value - ) - - # Consider max 5 devices created recently. - return devices["devices"][-MAX_DEVICES_FOR_PUSH::] - @classmethod async def get_push_content(cls, event: EventModel, _body: dict): push_detail = await PushNotificationRepository.get_push_notification(event.id)