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 2 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
73 changes: 73 additions & 0 deletions care/utils/sms/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file.
27 changes: 27 additions & 0 deletions care/utils/sms/backend/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from care.utils.sms.message import TextMessage


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

Subclasses should override `send_messages`.
"""
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved

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`.")
26 changes: 26 additions & 0 deletions care/utils/sms/backend/console.py
Original file line number Diff line number Diff line change
@@ -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
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved
69 changes: 69 additions & 0 deletions care/utils/sms/backend/sns.py
Original file line number Diff line number Diff line change
@@ -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):
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.

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
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved
return successful_sends
68 changes: 68 additions & 0 deletions care/utils/sms/message.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Member

Choose a reason for hiding this comment

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

Why is this class created twice, am i missing something?

"""
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)
DraKen0009 marked this conversation as resolved.
Show resolved Hide resolved
return connection.send_message(self)
35 changes: 0 additions & 35 deletions care/utils/sms/send_sms.py

This file was deleted.

Loading