Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added configarable sms support #2697

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions care/emr/api/otp_viewsets/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
import logging

Expand Down
10 changes: 6 additions & 4 deletions care/facility/api/serializers/patient_otp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not have a separate initialize, let the class do it if needed. we dont need to explicitly state it

connection.send_message(message)
elif settings.DEBUG:
print(otp, otp_obj.phone_number) # noqa: T201

Expand Down
11 changes: 6 additions & 5 deletions care/utils/notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down
133 changes: 133 additions & 0 deletions care/utils/sms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from typing import TYPE_CHECKING

from django.conf import settings
from django.utils.module_loading import import_string

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":
"""
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.
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=fail_silently, **kwargs)


def send_text_message(
content: str = "",
sender: str | None = None,
recipients: str | list[str] | None = None,
fail_silently: bool = False,
backend_instance: type["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.
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:
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(fail_silently=fail_silently)


def get_sms_backend(
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.
fail_silently (bool): Whether to suppress 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,
fail_silently=fail_silently,
**kwargs,
)
Empty file.
38 changes: 38 additions & 0 deletions care/utils/sms/backend/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from care.utils.sms.message import TextMessage


class SmsBackendBase:
"""
Base class for all SMS backends.

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 a text message.

Subclasses must implement this method to handle the logic for sending
messages using the specific backend.

Args:
message (TextMessage): The message to be sent.

Raises:
NotImplementedError: If the method is not implemented in a subclass.

Returns:
int: The number of messages successfully sent.
"""
raise NotImplementedError("Subclasses must implement `send_message`.")
43 changes: 43 additions & 0 deletions care/utils/sms/backend/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 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_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:
self.stream.write(
f"From: {message.sender}\nTo: {recipient}\nContent: {message.content}\n{'-' * 50}\n"
)
sent_count += 1
return sent_count
92 changes: 92 additions & 0 deletions care/utils/sms/backend/sns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can create an instance of the backend class in the settings file itself and re-use it, we dont have to go through the configuration everytime.

"""
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:
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 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=self.region_name,
)
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_ACCESS_KEY', and 'SNS_SECRET_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:
"""
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

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
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved
return successful_sends
Loading