From f673760f2bcb3e09440fb6ee3f07de559c367100 Mon Sep 17 00:00:00 2001 From: Prafful Date: Thu, 2 Jan 2025 17:19:02 +0530 Subject: [PATCH 1/4] added configarable sms support --- care/emr/api/otp_viewsets/login.py | 10 +-- care/facility/api/serializers/patient_otp.py | 10 +-- care/utils/notification_handler.py | 11 +-- care/utils/sms/__init__.py | 73 ++++++++++++++++++++ care/utils/sms/backend/__init__.py | 0 care/utils/sms/backend/base.py | 27 ++++++++ care/utils/sms/backend/console.py | 26 +++++++ care/utils/sms/backend/sns.py | 69 ++++++++++++++++++ care/utils/sms/message.py | 68 ++++++++++++++++++ care/utils/sms/send_sms.py | 35 ---------- care/utils/tests/test_sms.py | 62 +++++++++++++++++ 11 files changed, 343 insertions(+), 48 deletions(-) create mode 100644 care/utils/sms/backend/__init__.py create mode 100644 care/utils/sms/backend/base.py create mode 100644 care/utils/sms/backend/console.py create mode 100644 care/utils/sms/backend/sns.py create mode 100644 care/utils/sms/message.py delete mode 100644 care/utils/sms/send_sms.py create mode 100644 care/utils/tests/test_sms.py diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index baa5ecd042..793c294495 100644 --- a/care/emr/api/otp_viewsets/login.py +++ b/care/emr/api/otp_viewsets/login.py @@ -10,8 +10,8 @@ from care.emr.api.viewsets.base import EMRBaseViewSet from care.facility.api.serializers.patient_otp import rand_pass from care.facility.models import PatientMobileOTP +from care.utils import sms from care.utils.models.validators import mobile_validator -from care.utils.sms.send_sms import send_sms from config.patient_otp_token import PatientToken @@ -51,13 +51,15 @@ def send(self, request): if settings.USE_SMS: random_otp = rand_pass(settings.OTP_LENGTH) try: - send_sms( - data.phone_number, - ( + message = sms.TextMessage( + content=( f"Open Healthcare Network Patient Management System Login, OTP is {random_otp} . " "Please do not share this Confidential Login Token with anyone else" ), + recipients=[data.phone_number], ) + connection = sms.initialize_backend() + connection.send_message(message) except Exception as e: import logging diff --git a/care/facility/api/serializers/patient_otp.py b/care/facility/api/serializers/patient_otp.py index 2fed991ade..a8f81e88f2 100644 --- a/care/facility/api/serializers/patient_otp.py +++ b/care/facility/api/serializers/patient_otp.py @@ -8,7 +8,7 @@ from rest_framework.exceptions import ValidationError from care.facility.models.patient import PatientMobileOTP -from care.utils.sms.send_sms import send_sms +from care.utils import sms def rand_pass(size): @@ -41,13 +41,15 @@ def create(self, validated_data): otp = rand_pass(settings.OTP_LENGTH) if settings.USE_SMS: - send_sms( - otp_obj.phone_number, - ( + message = sms.TextMessage( + content=( f"Open Healthcare Network Patient Management System Login, OTP is {otp} . " "Please do not share this Confidential Login Token with anyone else" ), + recipients=[otp_obj.phone_number], ) + connection = sms.initialize_backend() + connection.send_message(message) elif settings.DEBUG: print(otp, otp_obj.phone_number) # noqa: T201 diff --git a/care/utils/notification_handler.py b/care/utils/notification_handler.py index eca26aa602..deeac61fe3 100644 --- a/care/utils/notification_handler.py +++ b/care/utils/notification_handler.py @@ -17,7 +17,7 @@ ) from care.facility.models.shifting import ShiftingRequest from care.users.models import User -from care.utils.sms.send_sms import send_sms +from care.utils import sms logger = logging.getLogger(__name__) @@ -371,11 +371,12 @@ def generate(self): medium == Notification.Medium.SMS.value and settings.SEND_SMS_NOTIFICATION ): - send_sms( - self.generate_sms_phone_numbers(), - self.generate_sms_message(), - many=True, + message = sms.TextMessage( + content=self.generate_sms_message(), + recipients=self.generate_sms_phone_numbers(), ) + connection = sms.initialize_backend() + connection.send_message(message) elif medium == Notification.Medium.SYSTEM.value: if not self.message: self.message = self.generate_system_message() diff --git a/care/utils/sms/__init__.py b/care/utils/sms/__init__.py index e69de29bb2..38ff73588a 100644 --- a/care/utils/sms/__init__.py +++ b/care/utils/sms/__init__.py @@ -0,0 +1,73 @@ +from django.conf import settings +from django.utils.module_loading import import_string + +from care.utils.sms.backend.base import SmsBackendBase +from care.utils.sms.message import TextMessage + +__all__ = ["TextMessage", "initialize_backend", "send_text_message"] + + +def initialize_backend( + backend_name: str | None = None, suppress_errors: bool = False, **kwargs +) -> SmsBackendBase: + """ + Load and configure an SMS backend. + + Args: + backend_name (Optional[str]): The dotted path to the backend class. If None, the default backend from settings is used. + suppress_errors (bool): Whether to handle exceptions quietly. Defaults to False. + + Returns: + SmsBackendBase: An initialized instance of the specified SMS backend. + """ + backend_class = import_string(backend_name or settings.SMS_BACKEND) + return backend_class(fail_silently=suppress_errors, **kwargs) + + +def send_text_message( + content: str = "", + sender: str | None = None, + recipients: str | list[str] | None = None, + suppress_errors: bool = False, + backend_instance: SmsBackendBase | None = None, +) -> int: + """ + Send a single SMS message to one or more recipients. + + Args: + content (str): The message content to be sent. Defaults to an empty string. + sender (Optional[str]): The sender's phone number. Defaults to None. + recipients (Union[str, List[str], None]): A single recipient or a list of recipients. Defaults to None. + suppress_errors (bool): Whether to suppress exceptions during sending. Defaults to False. + backend_instance (Optional[SmsBackendBase]): A pre-configured SMS backend instance. Defaults to None. + + Returns: + int: The number of messages successfully sent. + """ + if isinstance(recipients, str): + recipients = [recipients] + message = TextMessage( + content=content, sender=sender, recipients=recipients, backend=backend_instance + ) + return message.dispatch(silent_fail=suppress_errors) + + +def get_sms_backend( + backend_name: str | None = None, suppress_errors: bool = False, **kwargs +) -> SmsBackendBase: + """ + Load and return an SMS backend instance. + + Args: + backend_name (Optional[str]): The dotted path to the backend class. If None, the default backend from settings is used. + suppress_errors (bool): Whether to handle exceptions quietly. Defaults to False. + **kwargs: Additional arguments passed to the backend constructor. + + Returns: + SmsBackendBase: An initialized instance of the specified SMS backend. + """ + return initialize_backend( + backend_name=backend_name or settings.SMS_BACKEND, + suppress_errors=suppress_errors, + **kwargs, + ) diff --git a/care/utils/sms/backend/__init__.py b/care/utils/sms/backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/sms/backend/base.py b/care/utils/sms/backend/base.py new file mode 100644 index 0000000000..38b05602cb --- /dev/null +++ b/care/utils/sms/backend/base.py @@ -0,0 +1,27 @@ +from care.utils.sms.message import TextMessage + + +class SmsBackendBase: + """ + Base class for all SMS backends. + + Subclasses should override `send_messages`. + """ + + def __init__(self, fail_silently: bool = False, **kwargs) -> None: + self.fail_silently = fail_silently + + def send_message(self, message: TextMessage) -> int: + """ + Send one or more text messages. + + Args: + messages (List[TextMessage]): List of messages to send. + + Raises: + NotImplementedError: If not implemented in subclass. + + Returns: + int: Number of messages sent. + """ + raise NotImplementedError("Subclasses must implement `send_messages`.") diff --git a/care/utils/sms/backend/console.py b/care/utils/sms/backend/console.py new file mode 100644 index 0000000000..1a1fd9b411 --- /dev/null +++ b/care/utils/sms/backend/console.py @@ -0,0 +1,26 @@ +import sys +import threading + +from care.utils.sms.backend.base import SmsBackendBase +from care.utils.sms.message import TextMessage + + +class ConsoleBackend(SmsBackendBase): + """ + Outputs SMS messages to the console for debugging. + """ + + def __init__(self, *args, stream=None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.stream = stream or sys.stdout + self._lock = threading.RLock() + + def send_messages(self, message: TextMessage) -> int: + sent_count = 0 + with self._lock: + for recipient in message.recipients: + self.stream.write( + f"From: {message.sender}\nTo: {recipient}\nContent: {message.content}\n{'-' * 50}\n" + ) + sent_count += 1 + return sent_count diff --git a/care/utils/sms/backend/sns.py b/care/utils/sms/backend/sns.py new file mode 100644 index 0000000000..a11292529d --- /dev/null +++ b/care/utils/sms/backend/sns.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from care.utils.sms.backend.base import SmsBackendBase +from care.utils.sms.message import TextMessage + +try: + import boto3 + from botocore.exceptions import ClientError + + HAS_BOTO3 = True +except ImportError: + HAS_BOTO3 = False + + +class SnsBackend(SmsBackendBase): + def __init__(self, fail_silently: bool = False, **kwargs) -> None: + super().__init__(fail_silently=fail_silently, **kwargs) + + if not HAS_BOTO3 and not self.fail_silently: + raise ImproperlyConfigured("Boto3 library is required but not installed.") + + self.region_name = getattr(settings, "SNS_REGION", None) + self.access_key_id = getattr(settings, "SNS_ACCESS_KEY", None) + self.secret_access_key = getattr(settings, "SNS_SECRET_KEY", None) + + self.sns_client = None + if HAS_BOTO3: + if settings.SNS_ROLE_BASED_MODE: + if not self.region_name: + raise ImproperlyConfigured( + "AWS SNS is not configured. Check 'SNS_REGION' in settings." + ) + self.sns_client = boto3.client( + "sns", + region_name=settings.SNS_REGION, + ) + else: + if ( + not self.region_name + or not self.access_key_id + or not self.secret_access_key + ): + raise ImproperlyConfigured( + "AWS SNS credentials are not fully configured. Check 'SNS_REGION','SNS_SECRET_KEY', and 'SNS_ACCESS_KEY' in settings." + ) + self.sns_client = boto3.client( + "sns", + region_name=self.region_name, + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + ) + + def send_message(self, message: TextMessage) -> int: + if not self.sns_client: + return 0 + + successful_sends = 0 + for recipient in message.recipients: + try: + self.sns_client.publish( + PhoneNumber=recipient, + Message=message.content, + ) + successful_sends += 1 + except ClientError as error: + if not self.fail_silently: + raise error + return successful_sends diff --git a/care/utils/sms/message.py b/care/utils/sms/message.py new file mode 100644 index 0000000000..e421682ec6 --- /dev/null +++ b/care/utils/sms/message.py @@ -0,0 +1,68 @@ +from typing import TYPE_CHECKING + +from django.conf import settings + +if TYPE_CHECKING: + from care.utils.sms.backend.base import SmsBackendBase + + +class TextMessage: + """ + Represents a text message for transmission to one or more recipients. + """ + + def __init__( + self, + content: str = "", + sender: str | None = None, + recipients: list[str] | None = None, + backend: type["SmsBackendBase"] | None = None, + ) -> None: + """ + Initialize a TextMessage instance. + + Args: + content (str): The message content. + sender (Optional[str]): The sender's phone number. + recipients (Optional[List[str]]): List of recipient phone numbers. + backend (Optional[Type['SmsBackendBase']]): Backend for sending the message. + """ + self.content = content + self.sender = sender or getattr(settings, "DEFAULT_SMS_SENDER", "") + self.recipients = recipients or [] + self.backend = backend + + if isinstance(self.recipients, str): + raise ValueError("Recipients should be a list of phone numbers.") + + def establish_backend(self, silent_fail: bool = False) -> type["SmsBackendBase"]: + """ + Obtain or initialize the backend for sending messages. + + Args: + silent_fail (bool): Whether errors should be suppressed. + + Returns: + SmsBackendBase: An instance of the configured backend. + """ + from sms import get_sms_backend + + if not self.backend: + self.backend = get_sms_backend(silent_fail=silent_fail) + return self.backend + + def dispatch(self, silent_fail: bool = False) -> int: + """ + Send the message to all designated recipients. + + Args: + silent_fail (bool): Whether to suppress any errors. + + Returns: + int: Count of successfully sent messages. + """ + if not self.recipients: + return 0 + + connection = self.establish_backend(silent_fail) + return connection.send_message(self) diff --git a/care/utils/sms/send_sms.py b/care/utils/sms/send_sms.py deleted file mode 100644 index fe5f497b7c..0000000000 --- a/care/utils/sms/send_sms.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging - -import boto3 -from django.conf import settings - -from care.utils.models.validators import mobile_validator - -logger = logging.getLogger(__name__) - - -def send_sms(phone_numbers, message, many=False): - if not many: - phone_numbers = [phone_numbers] - phone_numbers = list(set(phone_numbers)) - for phone in phone_numbers: - try: - mobile_validator(phone) - except Exception: - if settings.DEBUG: - logger.error("Invalid Phone Number %s", phone) - continue - if settings.SNS_ROLE_BASED_MODE: - client = boto3.client( - "sns", - region_name=settings.SNS_REGION, - ) - else: - client = boto3.client( - "sns", - aws_access_key_id=settings.SNS_ACCESS_KEY, - aws_secret_access_key=settings.SNS_SECRET_KEY, - region_name=settings.SNS_REGION, - ) - client.publish(PhoneNumber=phone, Message=message) - return True diff --git a/care/utils/tests/test_sms.py b/care/utils/tests/test_sms.py new file mode 100644 index 0000000000..771673970c --- /dev/null +++ b/care/utils/tests/test_sms.py @@ -0,0 +1,62 @@ +from io import StringIO +from unittest.mock import MagicMock + +from django.test import SimpleTestCase, override_settings + +from care.utils import sms +from care.utils.sms.message import TextMessage + + +class BaseSmsBackendTests: + sms_backend: str | None = None + + def setUp(self) -> None: + self.settings_override = override_settings(SMS_BACKEND=self.sms_backend) + self.settings_override.enable() + + def tearDown(self) -> None: + self.settings_override.disable() + + +class ConsoleBackendTests(BaseSmsBackendTests, SimpleTestCase): + sms_backend: str = "care.utils.sms.backend.console.ConsoleBackend" + + def test_console_stream_kwarg(self) -> None: + stream = StringIO() + connection = sms.initialize_backend(self.sms_backend, stream=stream) + message = TextMessage("Content", "0600000000", ["0600000000"]) + connection.send_messages(message) + messages = stream.getvalue().split("\n" + ("-" * 79) + "\n") + self.assertIn("From: ", messages[0]) + + +class AwsBackendTests(BaseSmsBackendTests, SimpleTestCase): + sms_backend = "care.utils.sms.backend.sns.SnsBackend" + + def setUp(self) -> None: + super().setUp() + self._settings_override = override_settings( + SNS_REGION="us-moon-3", + SNS_ACCESS_KEY="AKIAFAKEACCESSKEYID", + SNS_SECRET_KEY="fake_secret_access_key", + SNS_ROLE_BASED_MODE=True, + ) + self._settings_override.enable() + + def tearDown(self) -> None: + self._settings_override.disable() + super().tearDown() + + def test_send_messages(self) -> None: + message = TextMessage( + content="Here is the message", + sender="+12065550100", + recipients=["+441134960000"], + ) + connection = sms.initialize_backend(self.sms_backend) + connection.sns_client.publish = MagicMock() + connection.send_message(message) + connection.sns_client.publish.assert_called_with( + PhoneNumber="+441134960000", + Message="Here is the message", + ) From 5a034c6d57dd7850a4cf9dbc081dcec792bafe81 Mon Sep 17 00:00:00 2001 From: Prafful Date: Thu, 2 Jan 2025 18:00:20 +0530 Subject: [PATCH 2/4] fixed function mismatching --- care/utils/sms/__init__.py | 90 +++++++++++++++++++++++++------ care/utils/sms/backend/base.py | 23 +++++--- care/utils/sms/backend/console.py | 21 +++++++- care/utils/sms/backend/sns.py | 29 ++++++++-- care/utils/sms/message.py | 18 +++---- care/utils/tests/test_sms.py | 4 +- 6 files changed, 148 insertions(+), 37 deletions(-) diff --git a/care/utils/sms/__init__.py b/care/utils/sms/__init__.py index 38ff73588a..b826e8512b 100644 --- a/care/utils/sms/__init__.py +++ b/care/utils/sms/__init__.py @@ -1,35 +1,95 @@ +from typing import TYPE_CHECKING + from django.conf import settings from django.utils.module_loading import import_string -from care.utils.sms.backend.base import SmsBackendBase -from care.utils.sms.message import TextMessage +if TYPE_CHECKING: + from care.utils.sms.backend.base import SmsBackendBase + + +class TextMessage: + """ + Represents a text message for transmission to one or more recipients. + """ + + def __init__( + self, + content: str = "", + sender: str | None = None, + recipients: list[str] | None = None, + backend: type["SmsBackendBase"] | None = None, + ) -> None: + """ + Initialize a TextMessage instance. + + Args: + content (str): The message content. + sender (Optional[str]): The sender's phone number. + recipients (Optional[List[str]]): List of recipient phone numbers. + backend (Optional[SmsBackendBase]): Backend for sending the message. + """ + self.content = content + self.sender = sender or getattr(settings, "DEFAULT_SMS_SENDER", "") + self.recipients = recipients or [] + self.backend = backend + + if isinstance(self.recipients, str): + raise ValueError("Recipients should be a list of phone numbers.") + + def establish_backend(self, fail_silently: bool = False) -> "SmsBackendBase": + """ + Obtain or initialize the backend for sending messages. + + Args: + fail_silently (bool): Whether to suppress errors during backend initialization. + + Returns: + SmsBackendBase: An instance of the configured backend. + """ + if not self.backend: + self.backend = get_sms_backend(fail_silently=fail_silently) + return self.backend + + def dispatch(self, fail_silently: bool = False) -> int: + """ + Send the message to all designated recipients. + + Args: + fail_silently (bool): Whether to suppress errors during message sending. + + Returns: + int: Count of successfully sent messages. + """ + if not self.recipients: + return 0 -__all__ = ["TextMessage", "initialize_backend", "send_text_message"] + connection = self.establish_backend(fail_silently) + return connection.send_messages([self]) def initialize_backend( - backend_name: str | None = None, suppress_errors: bool = False, **kwargs -) -> SmsBackendBase: + backend_name: str | None = None, fail_silently: bool = False, **kwargs +) -> "SmsBackendBase": """ Load and configure an SMS backend. Args: backend_name (Optional[str]): The dotted path to the backend class. If None, the default backend from settings is used. - suppress_errors (bool): Whether to handle exceptions quietly. Defaults to False. + fail_silently (bool): Whether to handle exceptions quietly. Defaults to False. Returns: SmsBackendBase: An initialized instance of the specified SMS backend. """ backend_class = import_string(backend_name or settings.SMS_BACKEND) - return backend_class(fail_silently=suppress_errors, **kwargs) + return backend_class(fail_silently=fail_silently, **kwargs) def send_text_message( content: str = "", sender: str | None = None, recipients: str | list[str] | None = None, - suppress_errors: bool = False, - backend_instance: SmsBackendBase | None = None, + fail_silently: bool = False, + backend_instance: type["SmsBackendBase"] | None = None, ) -> int: """ Send a single SMS message to one or more recipients. @@ -38,7 +98,7 @@ def send_text_message( content (str): The message content to be sent. Defaults to an empty string. sender (Optional[str]): The sender's phone number. Defaults to None. recipients (Union[str, List[str], None]): A single recipient or a list of recipients. Defaults to None. - suppress_errors (bool): Whether to suppress exceptions during sending. Defaults to False. + fail_silently (bool): Whether to suppress exceptions during sending. Defaults to False. backend_instance (Optional[SmsBackendBase]): A pre-configured SMS backend instance. Defaults to None. Returns: @@ -49,18 +109,18 @@ def send_text_message( message = TextMessage( content=content, sender=sender, recipients=recipients, backend=backend_instance ) - return message.dispatch(silent_fail=suppress_errors) + return message.dispatch(fail_silently=fail_silently) def get_sms_backend( - backend_name: str | None = None, suppress_errors: bool = False, **kwargs -) -> SmsBackendBase: + backend_name: str | None = None, fail_silently: bool = False, **kwargs +) -> "SmsBackendBase": """ Load and return an SMS backend instance. Args: backend_name (Optional[str]): The dotted path to the backend class. If None, the default backend from settings is used. - suppress_errors (bool): Whether to handle exceptions quietly. Defaults to False. + fail_silently (bool): Whether to suppress exceptions quietly. Defaults to False. **kwargs: Additional arguments passed to the backend constructor. Returns: @@ -68,6 +128,6 @@ def get_sms_backend( """ return initialize_backend( backend_name=backend_name or settings.SMS_BACKEND, - suppress_errors=suppress_errors, + fail_silently=fail_silently, **kwargs, ) diff --git a/care/utils/sms/backend/base.py b/care/utils/sms/backend/base.py index 38b05602cb..1ba58fc2e8 100644 --- a/care/utils/sms/backend/base.py +++ b/care/utils/sms/backend/base.py @@ -5,23 +5,34 @@ class SmsBackendBase: """ Base class for all SMS backends. - Subclasses should override `send_messages`. + Subclasses should override the `send_message` method to provide the logic + for sending SMS messages. """ def __init__(self, fail_silently: bool = False, **kwargs) -> None: + """ + Initialize the SMS backend. + + Args: + fail_silently (bool): Whether to suppress exceptions during message sending. Defaults to False. + **kwargs: Additional arguments for backend configuration. + """ self.fail_silently = fail_silently def send_message(self, message: TextMessage) -> int: """ - Send one or more text messages. + Send a text message. + + Subclasses must implement this method to handle the logic for sending + messages using the specific backend. Args: - messages (List[TextMessage]): List of messages to send. + message (TextMessage): The message to be sent. Raises: - NotImplementedError: If not implemented in subclass. + NotImplementedError: If the method is not implemented in a subclass. Returns: - int: Number of messages sent. + int: The number of messages successfully sent. """ - raise NotImplementedError("Subclasses must implement `send_messages`.") + raise NotImplementedError("Subclasses must implement `send_message`.") diff --git a/care/utils/sms/backend/console.py b/care/utils/sms/backend/console.py index 1a1fd9b411..6be085442a 100644 --- a/care/utils/sms/backend/console.py +++ b/care/utils/sms/backend/console.py @@ -7,15 +7,32 @@ class ConsoleBackend(SmsBackendBase): """ - Outputs SMS messages to the console for debugging. + Outputs SMS messages to the console for debugging purposes. """ def __init__(self, *args, stream=None, **kwargs) -> None: + """ + Initialize the ConsoleBackend. + + Args: + stream (Optional[TextIO]): The output stream to write messages to. Defaults to sys.stdout. + *args: Additional arguments for the superclass. + **kwargs: Additional keyword arguments for the superclass. + """ super().__init__(*args, **kwargs) self.stream = stream or sys.stdout self._lock = threading.RLock() - def send_messages(self, message: TextMessage) -> int: + def send_message(self, message: TextMessage) -> int: + """ + Write the SMS message to the console. + + Args: + message (TextMessage): The message to be sent. + + Returns: + int: The number of messages successfully "sent" (i.e., written to the console). + """ sent_count = 0 with self._lock: for recipient in message.recipients: diff --git a/care/utils/sms/backend/sns.py b/care/utils/sms/backend/sns.py index a11292529d..b1ec0b730d 100644 --- a/care/utils/sms/backend/sns.py +++ b/care/utils/sms/backend/sns.py @@ -14,7 +14,21 @@ class SnsBackend(SmsBackendBase): + """ + Sends SMS messages using AWS SNS. + """ + def __init__(self, fail_silently: bool = False, **kwargs) -> None: + """ + Initialize the SNS backend. + + Args: + fail_silently (bool): Whether to suppress exceptions during initialization. Defaults to False. + **kwargs: Additional arguments for backend configuration. + + Raises: + ImproperlyConfigured: If required AWS SNS settings are missing or boto3 is not installed. + """ super().__init__(fail_silently=fail_silently, **kwargs) if not HAS_BOTO3 and not self.fail_silently: @@ -26,14 +40,14 @@ def __init__(self, fail_silently: bool = False, **kwargs) -> None: self.sns_client = None if HAS_BOTO3: - if settings.SNS_ROLE_BASED_MODE: + if getattr(settings, "SNS_ROLE_BASED_MODE", False): if not self.region_name: raise ImproperlyConfigured( "AWS SNS is not configured. Check 'SNS_REGION' in settings." ) self.sns_client = boto3.client( "sns", - region_name=settings.SNS_REGION, + region_name=self.region_name, ) else: if ( @@ -42,7 +56,7 @@ def __init__(self, fail_silently: bool = False, **kwargs) -> None: or not self.secret_access_key ): raise ImproperlyConfigured( - "AWS SNS credentials are not fully configured. Check 'SNS_REGION','SNS_SECRET_KEY', and 'SNS_ACCESS_KEY' in settings." + "AWS SNS credentials are not fully configured. Check 'SNS_REGION', 'SNS_ACCESS_KEY', and 'SNS_SECRET_KEY' in settings." ) self.sns_client = boto3.client( "sns", @@ -52,6 +66,15 @@ def __init__(self, fail_silently: bool = False, **kwargs) -> None: ) def send_message(self, message: TextMessage) -> int: + """ + Send a text message using AWS SNS. + + Args: + message (TextMessage): The message to be sent. + + Returns: + int: The number of messages successfully sent. + """ if not self.sns_client: return 0 diff --git a/care/utils/sms/message.py b/care/utils/sms/message.py index e421682ec6..e4d28c7bc8 100644 --- a/care/utils/sms/message.py +++ b/care/utils/sms/message.py @@ -25,7 +25,7 @@ def __init__( content (str): The message content. sender (Optional[str]): The sender's phone number. recipients (Optional[List[str]]): List of recipient phone numbers. - backend (Optional[Type['SmsBackendBase']]): Backend for sending the message. + backend (Optional[SmsBackendBase]): Backend for sending the message. """ self.content = content self.sender = sender or getattr(settings, "DEFAULT_SMS_SENDER", "") @@ -35,28 +35,28 @@ def __init__( if isinstance(self.recipients, str): raise ValueError("Recipients should be a list of phone numbers.") - def establish_backend(self, silent_fail: bool = False) -> type["SmsBackendBase"]: + def establish_backend(self, fail_silently: bool = False) -> "SmsBackendBase": """ Obtain or initialize the backend for sending messages. Args: - silent_fail (bool): Whether errors should be suppressed. + fail_silently (bool): Whether to suppress errors during backend initialization. Returns: SmsBackendBase: An instance of the configured backend. """ - from sms import get_sms_backend + from care.utils.sms import get_sms_backend if not self.backend: - self.backend = get_sms_backend(silent_fail=silent_fail) + self.backend = get_sms_backend(fail_silently=fail_silently) return self.backend - def dispatch(self, silent_fail: bool = False) -> int: + def dispatch(self, fail_silently: bool = False) -> int: """ Send the message to all designated recipients. Args: - silent_fail (bool): Whether to suppress any errors. + fail_silently (bool): Whether to suppress errors during message sending. Returns: int: Count of successfully sent messages. @@ -64,5 +64,5 @@ def dispatch(self, silent_fail: bool = False) -> int: if not self.recipients: return 0 - connection = self.establish_backend(silent_fail) - return connection.send_message(self) + connection = self.establish_backend(fail_silently) + return connection.send_messages([self]) diff --git a/care/utils/tests/test_sms.py b/care/utils/tests/test_sms.py index 771673970c..8d85c7e4f6 100644 --- a/care/utils/tests/test_sms.py +++ b/care/utils/tests/test_sms.py @@ -25,7 +25,7 @@ def test_console_stream_kwarg(self) -> None: stream = StringIO() connection = sms.initialize_backend(self.sms_backend, stream=stream) message = TextMessage("Content", "0600000000", ["0600000000"]) - connection.send_messages(message) + connection.send_message(message) messages = stream.getvalue().split("\n" + ("-" * 79) + "\n") self.assertIn("From: ", messages[0]) @@ -47,7 +47,7 @@ def tearDown(self) -> None: self._settings_override.disable() super().tearDown() - def test_send_messages(self) -> None: + def test_send_message(self) -> None: message = TextMessage( content="Here is the message", sender="+12065550100", From 7e45603d3712521c8dc629ced27c00c5eb991181 Mon Sep 17 00:00:00 2001 From: Prafful Date: Fri, 3 Jan 2025 14:26:05 +0530 Subject: [PATCH 3/4] updated as per comments --- care/emr/api/otp_viewsets/login.py | 4 +- care/facility/api/serializers/patient_otp.py | 4 +- care/utils/notification_handler.py | 4 +- care/utils/sms/__init__.py | 68 ++----------------- care/utils/sms/backend/console.py | 3 +- care/utils/sms/backend/sns.py | 70 +++++++++++--------- care/utils/sms/message.py | 26 +++----- care/utils/tests/test_sms.py | 62 ----------------- config/settings/base.py | 2 + config/settings/deployment.py | 2 + 10 files changed, 61 insertions(+), 184 deletions(-) delete mode 100644 care/utils/tests/test_sms.py diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index 793c294495..9d6cb73745 100644 --- a/care/emr/api/otp_viewsets/login.py +++ b/care/emr/api/otp_viewsets/login.py @@ -51,15 +51,13 @@ def send(self, request): if settings.USE_SMS: random_otp = rand_pass(settings.OTP_LENGTH) try: - message = sms.TextMessage( + sms.send_text_message( content=( f"Open Healthcare Network Patient Management System Login, OTP is {random_otp} . " "Please do not share this Confidential Login Token with anyone else" ), recipients=[data.phone_number], ) - connection = sms.initialize_backend() - connection.send_message(message) except Exception as e: import logging diff --git a/care/facility/api/serializers/patient_otp.py b/care/facility/api/serializers/patient_otp.py index a8f81e88f2..3de60c49b7 100644 --- a/care/facility/api/serializers/patient_otp.py +++ b/care/facility/api/serializers/patient_otp.py @@ -41,15 +41,13 @@ def create(self, validated_data): otp = rand_pass(settings.OTP_LENGTH) if settings.USE_SMS: - message = sms.TextMessage( + sms.send_text_message( content=( f"Open Healthcare Network Patient Management System Login, OTP is {otp} . " "Please do not share this Confidential Login Token with anyone else" ), recipients=[otp_obj.phone_number], ) - connection = sms.initialize_backend() - connection.send_message(message) elif settings.DEBUG: print(otp, otp_obj.phone_number) # noqa: T201 diff --git a/care/utils/notification_handler.py b/care/utils/notification_handler.py index deeac61fe3..5325b6f5c9 100644 --- a/care/utils/notification_handler.py +++ b/care/utils/notification_handler.py @@ -371,12 +371,10 @@ def generate(self): medium == Notification.Medium.SMS.value and settings.SEND_SMS_NOTIFICATION ): - message = sms.TextMessage( + sms.send_text_message( content=self.generate_sms_message(), recipients=self.generate_sms_phone_numbers(), ) - connection = sms.initialize_backend() - connection.send_message(message) elif medium == Notification.Medium.SYSTEM.value: if not self.message: self.message = self.generate_system_message() diff --git a/care/utils/sms/__init__.py b/care/utils/sms/__init__.py index b826e8512b..6742c3fffd 100644 --- a/care/utils/sms/__init__.py +++ b/care/utils/sms/__init__.py @@ -3,70 +3,12 @@ from django.conf import settings from django.utils.module_loading import import_string +from care.utils.sms.message import TextMessage + if TYPE_CHECKING: from care.utils.sms.backend.base import SmsBackendBase -class TextMessage: - """ - Represents a text message for transmission to one or more recipients. - """ - - def __init__( - self, - content: str = "", - sender: str | None = None, - recipients: list[str] | None = None, - backend: type["SmsBackendBase"] | None = None, - ) -> None: - """ - Initialize a TextMessage instance. - - Args: - content (str): The message content. - sender (Optional[str]): The sender's phone number. - recipients (Optional[List[str]]): List of recipient phone numbers. - backend (Optional[SmsBackendBase]): Backend for sending the message. - """ - self.content = content - self.sender = sender or getattr(settings, "DEFAULT_SMS_SENDER", "") - self.recipients = recipients or [] - self.backend = backend - - if isinstance(self.recipients, str): - raise ValueError("Recipients should be a list of phone numbers.") - - def establish_backend(self, fail_silently: bool = False) -> "SmsBackendBase": - """ - Obtain or initialize the backend for sending messages. - - Args: - fail_silently (bool): Whether to suppress errors during backend initialization. - - Returns: - SmsBackendBase: An instance of the configured backend. - """ - if not self.backend: - self.backend = get_sms_backend(fail_silently=fail_silently) - return self.backend - - def dispatch(self, fail_silently: bool = False) -> int: - """ - Send the message to all designated recipients. - - Args: - fail_silently (bool): Whether to suppress errors during message sending. - - Returns: - int: Count of successfully sent messages. - """ - if not self.recipients: - return 0 - - connection = self.establish_backend(fail_silently) - return connection.send_messages([self]) - - def initialize_backend( backend_name: str | None = None, fail_silently: bool = False, **kwargs ) -> "SmsBackendBase": @@ -107,7 +49,11 @@ def send_text_message( if isinstance(recipients, str): recipients = [recipients] message = TextMessage( - content=content, sender=sender, recipients=recipients, backend=backend_instance + content=content, + sender=sender, + recipients=recipients, + backend=backend_instance, + fail_silently=fail_silently, ) return message.dispatch(fail_silently=fail_silently) diff --git a/care/utils/sms/backend/console.py b/care/utils/sms/backend/console.py index 6be085442a..0a4c30a9bb 100644 --- a/care/utils/sms/backend/console.py +++ b/care/utils/sms/backend/console.py @@ -37,7 +37,8 @@ def send_message(self, message: TextMessage) -> int: with self._lock: for recipient in message.recipients: self.stream.write( - f"From: {message.sender}\nTo: {recipient}\nContent: {message.content}\n{'-' * 50}\n" + f"From: {message.sender}\nTo: {recipient}\nContent: {message.content}\n{'-' * 100}\n" ) sent_count += 1 + self.stream.flush() return sent_count diff --git a/care/utils/sms/backend/sns.py b/care/utils/sms/backend/sns.py index b1ec0b730d..6bad9993e7 100644 --- a/care/utils/sms/backend/sns.py +++ b/care/utils/sms/backend/sns.py @@ -18,52 +18,57 @@ class SnsBackend(SmsBackendBase): Sends SMS messages using AWS SNS. """ - def __init__(self, fail_silently: bool = False, **kwargs) -> None: - """ - Initialize the SNS backend. - - Args: - fail_silently (bool): Whether to suppress exceptions during initialization. Defaults to False. - **kwargs: Additional arguments for backend configuration. + _sns_client = None - Raises: - ImproperlyConfigured: If required AWS SNS settings are missing or boto3 is not installed. + @classmethod + def _get_client(cls): """ - super().__init__(fail_silently=fail_silently, **kwargs) + Get or create the SNS client. - if not HAS_BOTO3 and not self.fail_silently: - raise ImproperlyConfigured("Boto3 library is required but not installed.") + Returns: + boto3.Client: The shared SNS client. + """ + if cls._sns_client is None: + region_name = getattr(settings, "SNS_REGION", None) - self.region_name = getattr(settings, "SNS_REGION", None) - self.access_key_id = getattr(settings, "SNS_ACCESS_KEY", None) - self.secret_access_key = getattr(settings, "SNS_SECRET_KEY", None) + if not HAS_BOTO3: + raise ImproperlyConfigured( + "Boto3 library is required but not installed." + ) - self.sns_client = None - if HAS_BOTO3: if getattr(settings, "SNS_ROLE_BASED_MODE", False): - if not self.region_name: + if not region_name: raise ImproperlyConfigured( "AWS SNS is not configured. Check 'SNS_REGION' in settings." ) - self.sns_client = boto3.client( + cls._sns_client = boto3.client( "sns", - region_name=self.region_name, + region_name=region_name, ) else: - if ( - not self.region_name - or not self.access_key_id - or not self.secret_access_key - ): + access_key_id = getattr(settings, "SNS_ACCESS_KEY", None) + secret_access_key = getattr(settings, "SNS_SECRET_KEY", None) + if not region_name or not access_key_id or not secret_access_key: raise ImproperlyConfigured( "AWS SNS credentials are not fully configured. Check 'SNS_REGION', 'SNS_ACCESS_KEY', and 'SNS_SECRET_KEY' in settings." ) - self.sns_client = boto3.client( + cls._sns_client = boto3.client( "sns", - region_name=self.region_name, - aws_access_key_id=self.access_key_id, - aws_secret_access_key=self.secret_access_key, + region_name=region_name, + aws_access_key_id=access_key_id, + aws_secret_access_key=secret_access_key, ) + return cls._sns_client + + def __init__(self, fail_silently: bool = False, **kwargs) -> None: + """ + Initialize the SNS backend. + + Args: + fail_silently (bool): Whether to suppress exceptions during initialization. Defaults to False. + **kwargs: Additional arguments for backend configuration. + """ + super().__init__(fail_silently=fail_silently, **kwargs) def send_message(self, message: TextMessage) -> int: """ @@ -75,13 +80,12 @@ def send_message(self, message: TextMessage) -> int: Returns: int: The number of messages successfully sent. """ - if not self.sns_client: - return 0 - + sns_client = self._get_client() successful_sends = 0 + for recipient in message.recipients: try: - self.sns_client.publish( + sns_client.publish( PhoneNumber=recipient, Message=message.content, ) diff --git a/care/utils/sms/message.py b/care/utils/sms/message.py index e4d28c7bc8..9e14680a44 100644 --- a/care/utils/sms/message.py +++ b/care/utils/sms/message.py @@ -17,6 +17,7 @@ def __init__( sender: str | None = None, recipients: list[str] | None = None, backend: type["SmsBackendBase"] | None = None, + fail_silently: bool = False, ) -> None: """ Initialize a TextMessage instance. @@ -32,24 +33,13 @@ def __init__( self.recipients = recipients or [] self.backend = backend - if isinstance(self.recipients, str): - raise ValueError("Recipients should be a list of phone numbers.") - - def establish_backend(self, fail_silently: bool = False) -> "SmsBackendBase": - """ - Obtain or initialize the backend for sending messages. - - Args: - fail_silently (bool): Whether to suppress errors during backend initialization. - - Returns: - SmsBackendBase: An instance of the configured backend. - """ - from care.utils.sms import get_sms_backend - if not self.backend: + from care.utils.sms import get_sms_backend + self.backend = get_sms_backend(fail_silently=fail_silently) - return self.backend + + if isinstance(self.recipients, str): + raise ValueError("Recipients should be a list of phone numbers.") def dispatch(self, fail_silently: bool = False) -> int: """ @@ -64,5 +54,5 @@ def dispatch(self, fail_silently: bool = False) -> int: if not self.recipients: return 0 - connection = self.establish_backend(fail_silently) - return connection.send_messages([self]) + connection = self.backend + return connection.send_message(self) diff --git a/care/utils/tests/test_sms.py b/care/utils/tests/test_sms.py deleted file mode 100644 index 8d85c7e4f6..0000000000 --- a/care/utils/tests/test_sms.py +++ /dev/null @@ -1,62 +0,0 @@ -from io import StringIO -from unittest.mock import MagicMock - -from django.test import SimpleTestCase, override_settings - -from care.utils import sms -from care.utils.sms.message import TextMessage - - -class BaseSmsBackendTests: - sms_backend: str | None = None - - def setUp(self) -> None: - self.settings_override = override_settings(SMS_BACKEND=self.sms_backend) - self.settings_override.enable() - - def tearDown(self) -> None: - self.settings_override.disable() - - -class ConsoleBackendTests(BaseSmsBackendTests, SimpleTestCase): - sms_backend: str = "care.utils.sms.backend.console.ConsoleBackend" - - def test_console_stream_kwarg(self) -> None: - stream = StringIO() - connection = sms.initialize_backend(self.sms_backend, stream=stream) - message = TextMessage("Content", "0600000000", ["0600000000"]) - connection.send_message(message) - messages = stream.getvalue().split("\n" + ("-" * 79) + "\n") - self.assertIn("From: ", messages[0]) - - -class AwsBackendTests(BaseSmsBackendTests, SimpleTestCase): - sms_backend = "care.utils.sms.backend.sns.SnsBackend" - - def setUp(self) -> None: - super().setUp() - self._settings_override = override_settings( - SNS_REGION="us-moon-3", - SNS_ACCESS_KEY="AKIAFAKEACCESSKEYID", - SNS_SECRET_KEY="fake_secret_access_key", - SNS_ROLE_BASED_MODE=True, - ) - self._settings_override.enable() - - def tearDown(self) -> None: - self._settings_override.disable() - super().tearDown() - - def test_send_message(self) -> None: - message = TextMessage( - content="Here is the message", - sender="+12065550100", - recipients=["+441134960000"], - ) - connection = sms.initialize_backend(self.sms_backend) - connection.sns_client.publish = MagicMock() - connection.send_message(message) - connection.sns_client.publish.assert_called_with( - PhoneNumber="+441134960000", - Message="Here is the message", - ) diff --git a/config/settings/base.py b/config/settings/base.py index 581a451704..19a2aa9673 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -648,3 +648,5 @@ SNOWSTORM_DEPLOYMENT_URL = env( "SNOWSTORM_DEPLOYMENT_URL", default="http://165.22.211.144/fhir" ) + +SMS_BACKEND = "care.utils.sms.backend.console.ConsoleBackend" diff --git a/config/settings/deployment.py b/config/settings/deployment.py index 855adc8801..d1cadbd50e 100644 --- a/config/settings/deployment.py +++ b/config/settings/deployment.py @@ -124,6 +124,8 @@ SNS_SECRET_KEY = env("SNS_SECRET_KEY", default="") SNS_REGION = env("SNS_REGION", default="ap-south-1") SNS_ROLE_BASED_MODE = env.bool("SNS_ROLE_BASED_MODE", default=False) +SMS_BACKEND = "care.utils.sms.backend.sns.SnsBackend" + # open id connect JWKS = JsonWebKey.import_key_set( From 98cfe0f669f5da2cd25304435fa3e909f1552ca4 Mon Sep 17 00:00:00 2001 From: Prafful Date: Fri, 3 Jan 2025 19:45:07 +0530 Subject: [PATCH 4/4] added tests --- care/utils/tests/test_sms.py | 125 +++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 care/utils/tests/test_sms.py diff --git a/care/utils/tests/test_sms.py b/care/utils/tests/test_sms.py new file mode 100644 index 0000000000..a068de5e65 --- /dev/null +++ b/care/utils/tests/test_sms.py @@ -0,0 +1,125 @@ +from io import StringIO +from unittest.mock import MagicMock, patch + +from botocore.exceptions import ClientError +from django.test import TestCase, override_settings + +from care.utils.sms import send_text_message + + +@override_settings( + SMS_BACKEND="care.utils.sms.backend.sns.SnsBackend", + SNS_REGION="us-east-1", + SNS_ACCESS_KEY="fake_access_key", + SNS_SECRET_KEY="fake_secret_key", + SNS_ROLE_BASED_MODE=False, +) +class TestSendTextMessage(TestCase): + @patch("care.utils.sms.backend.sns.SnsBackend._get_client") + def test_send_to_single_recipient(self, mock_get_client): + """Sending an SMS to one recipient should call publish once and return 1.""" + mock_sns_client = MagicMock() + mock_get_client.return_value = mock_sns_client + + sent_count = send_text_message( + content="Hello, world!", + recipients="+10000000000", + ) + + self.assertEqual(sent_count, 1) + mock_sns_client.publish.assert_called_once_with( + PhoneNumber="+10000000000", + Message="Hello, world!", + ) + + @patch("care.utils.sms.backend.sns.SnsBackend._get_client") + def test_send_to_multiple_recipients(self, mock_get_client): + """Sending an SMS to multiple recipients should call publish per recipient.""" + mock_sns_client = MagicMock() + mock_get_client.return_value = mock_sns_client + + recipients = ["+10000000000", "+20000000000"] + sent_count = send_text_message( + content="Group message", + recipients=recipients, + ) + + self.assertEqual(sent_count, 2) + self.assertEqual(mock_sns_client.publish.call_count, 2) + + @patch("care.utils.sms.backend.sns.SnsBackend._get_client") + def test_fail_silently_false_raises_error(self, mock_get_client): + """If publish fails and fail_silently=False, a ClientError should be raised.""" + mock_sns_client = MagicMock() + mock_sns_client.publish.side_effect = ClientError( + {"Error": {"Code": "MockError"}}, "Publish" + ) + mock_get_client.return_value = mock_sns_client + + with self.assertRaises(ClientError): + send_text_message( + content="Failing message", + recipients=["+30000000000"], + fail_silently=False, + ) + + @patch("care.utils.sms.backend.sns.SnsBackend._get_client") + def test_fail_silently_true_swallows_error(self, mock_get_client): + """If publish fails but fail_silently=True, no error should be raised.""" + mock_sns_client = MagicMock() + mock_sns_client.publish.side_effect = ClientError( + {"Error": {"Code": "MockError"}}, "Publish" + ) + mock_get_client.return_value = mock_sns_client + + sent_count = send_text_message( + content="Silently failing message", + recipients=["+40000000000"], + fail_silently=True, + ) + + self.assertEqual(sent_count, 0, "Should report 0 messages sent on failure") + self.assertEqual(mock_sns_client.publish.call_count, 1) + + +@override_settings( + SMS_BACKEND="care.utils.sms.backend.console.ConsoleBackend", +) +class TestTextMessageWithConsoleBackend(TestCase): + """ + Tests sending SMS via the ConsoleBackend, which writes messages to stdout. + """ + + @patch("sys.stdout", new_callable=StringIO) + def test_send_single_recipient(self, mock_stdout): + """ + Verifies a single message to one recipient is printed correctly + and returns a successful send count of 1. + """ + sent_count = send_text_message( + content="Hello via Console!", + recipients="+10000000000", + ) + self.assertEqual(sent_count, 1, "Should report 1 for one successful send") + + output = mock_stdout.getvalue() + self.assertIn("Hello via Console!", output) + self.assertIn("+10000000000", output) + + @patch("sys.stdout", new_callable=StringIO) + def test_send_multiple_recipients(self, mock_stdout): + """ + Verifies multiple messages are printed for multiple recipients + and that send_text_message reports the correct count. + """ + recipients = ["+20000000000", "+30000000000"] + sent_count = send_text_message( + content="Group console message", + recipients=recipients, + ) + self.assertEqual(sent_count, 2, "Should report 2 for two successful sends") + + output = mock_stdout.getvalue() + self.assertIn("Group console message", output) + for r in recipients: + self.assertIn(r, output)