Skip to content

Commit

Permalink
feat: [FC-0047] add functionality to send push notifications (#282)
Browse files Browse the repository at this point in the history
* feat: [FC-0047] add functionality to send push notifications

* fix: [FC-0047] fix tests

* fix: [FC-0047] unpin django-push-notifications versions and recompile requirements

* fix: [FC-0047] fix tests

* fix: [FC-0047] remove extra import

* refactor: [FC-0047] review issues

* test: [FC-0047] add unit tests

* style: [FC-0047] fix code style issues

* fix: sort imports with isort

* test: [FC-0047] add missing tests

* chore: [FC-0047] recompile requirements

* style: [FC-0047] fix import ordering

* style: [FC-0047] fix code style issues

* style: [FC-0047] refactor condition

* test: [FC-0047] add check for push notif channel

* chore: [FC-0047] bump version

---------

Co-authored-by: Glib Glugovskiy <[email protected]>
  • Loading branch information
NiedielnitsevIvan and GlugovGrGlib authored Jul 25, 2024
1 parent 38c4e18 commit 0c11505
Show file tree
Hide file tree
Showing 18 changed files with 674 additions and 32 deletions.
2 changes: 1 addition & 1 deletion edx_ace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .recipient import Recipient
from .recipient_resolver import RecipientResolver

__version__ = '1.9.1'
__version__ = '1.10.0'


__all__ = [
Expand Down
21 changes: 20 additions & 1 deletion edx_ace/ace.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@
)
ace.send(msg)
"""
import logging

from django.template import TemplateDoesNotExist

from edx_ace import delivery, policy, presentation
from edx_ace.channel import get_channel_for_message
from edx_ace.errors import ChannelError, UnsupportedChannelError

log = logging.getLogger(__name__)


def send(msg):
def send(msg, limit_to_channels=None):
"""
Send a message to a recipient.
Expand All @@ -37,19 +43,32 @@ def send(msg):
Args:
msg (Message): The message to send.
limit_to_channels (list of ChannelType, optional): If provided, only send the message over the specified
channels. If not provided, the message will be sent over all channels that the policies allow.
"""
msg.report_basics()

channels_for_message = policy.channels_for(msg)

for channel_type in channels_for_message:
if limit_to_channels and channel_type not in limit_to_channels:
log.debug('Skipping channel %s', channel_type)

try:
channel = get_channel_for_message(channel_type, msg)
except UnsupportedChannelError:
continue

try:
rendered_message = presentation.render(channel, msg)
except TemplateDoesNotExist as error:
msg.report(
'template_error',
'Unable to send message because template not found\n' + str(error)
)
continue

try:
delivery.deliver(channel, rendered_message, msg)
except ChannelError as error:
msg.report(
Expand Down
39 changes: 22 additions & 17 deletions edx_ace/channel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class ChannelMap:
"""
A class that represents a channel map, usually as described in Django settings and `setup.py` files.
"""

def __init__(self, channels_list):
"""
Initialize a ChannelMap.
Expand Down Expand Up @@ -170,27 +171,31 @@ def get_channel_for_message(channel_type, message):
Channel: The selected channel object.
"""
channels_map = channels()
channel_names = []

if channel_type == ChannelType.EMAIL:
if message.options.get('transactional'):
channel_names = [settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL, settings.ACE_CHANNEL_DEFAULT_EMAIL]
else:
channel_names = [settings.ACE_CHANNEL_DEFAULT_EMAIL]

try:
possible_channels = [
channels_map.get_channel_by_name(channel_type, channel_name)
for channel_name in channel_names
]
except KeyError:
return channels_map.get_default_channel(channel_type)

# First see if any channel specifically demands to deliver this message
for channel in possible_channels:
if channel.overrides_delivery_for_message(message):
return channel

# Else the normal path: use the preferred channel for this message type
elif channel_type == ChannelType.PUSH:
channel_names = [settings.ACE_CHANNEL_DEFAULT_PUSH]

try:
possible_channels = [
channels_map.get_channel_by_name(channel_type, channel_name)
for channel_name in channel_names
]
except KeyError:
return channels_map.get_default_channel(channel_type)

# First see if any channel specifically demands to deliver this message
for channel in possible_channels:
if channel.overrides_delivery_for_message(message):
return channel

# Else the normal path: use the preferred channel for this message type
if possible_channels:
return possible_channels[0]

return channels_map.get_default_channel(channel_type)
else:
return channels_map.get_default_channel(channel_type)
110 changes: 110 additions & 0 deletions edx_ace/channel/push_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Channel for sending push notifications.
"""
import logging
import re

from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert
from push_notifications.gcm import dict_to_fcm_message, send_message
from push_notifications.models import GCMDevice

from django.conf import settings

from edx_ace.channel import Channel, ChannelType
from edx_ace.errors import FatalChannelDeliveryError
from edx_ace.message import Message
from edx_ace.renderers import RenderedPushNotification

LOG = logging.getLogger(__name__)
APNS_DEFAULT_PRIORITY = '5'
APNS_DEFAULT_PUSH_TYPE = 'alert'


class PushNotificationChannel(Channel):
"""
A channel for sending push notifications.
"""

channel_type = ChannelType.PUSH

@classmethod
def enabled(cls):
"""
Returns true if the push notification settings are configured.
"""
return bool(getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None))

def deliver(self, message: Message, rendered_message: RenderedPushNotification) -> None:
"""
Transmit a rendered message to a recipient.
Args:
message: The message to transmit.
rendered_message: The rendered content of the message that has been personalized
for this particular recipient.
"""
device_tokens = self.get_user_device_tokens(message.recipient.lms_user_id)
if not device_tokens:
LOG.info(
'Recipient with ID %s has no push token. Skipping push notification.',
message.recipient.lms_user_id
)
return

for token in device_tokens:
self.send_message(message, token, rendered_message)

def send_message(self, message: Message, token: str, rendered_message: RenderedPushNotification) -> None:
"""
Send a push notification to a device by token.
"""
notification_data = {
'title': self.compress_spaces(rendered_message.title),
'body': self.compress_spaces(rendered_message.body),
'notification_key': token,
**message.context.get('push_notification_extra_context', {}),
}
message = dict_to_fcm_message(notification_data)
# Note: By default dict_to_fcm_message does not support APNS configuration,
# only Android configuration, so we need to collect and set it manually.
apns_config = self.collect_apns_config(notification_data)
message.apns = apns_config
try:
send_message(token, message, settings.FCM_APP_NAME)
except Exception as e:
LOG.exception('Failed to send push notification to %s', token)
raise FatalChannelDeliveryError(f'Failed to send push notification to {token}') from e

@staticmethod
def collect_apns_config(notification_data: dict) -> APNSConfig:
"""
Collect APNS configuration with payload for the push notification.
This APNSConfig must be set to notifications for Firebase to send push notifications to iOS devices.
Notification has default priority and visibility settings, described in Apple's documentation.
(https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns)
"""
apns_alert = ApsAlert(title=notification_data['title'], body=notification_data['body'])
aps = Aps(alert=apns_alert, sound='default')
return APNSConfig(
headers={'apns-priority': APNS_DEFAULT_PRIORITY, 'apns-push-type': APNS_DEFAULT_PUSH_TYPE},
payload=APNSPayload(aps)
)

@staticmethod
def get_user_device_tokens(user_id: int) -> list:
"""
Get the device tokens for a user.
"""
return list(GCMDevice.objects.filter(
user_id=user_id,
cloud_message_type='FCM',
active=True,
).values_list('registration_id', flat=True))

@staticmethod
def compress_spaces(html_str: str) -> str:
"""
Compress spaces and remove newlines to make it easier to author templates.
"""
return re.sub('\\s+', ' ', html_str, re.UNICODE).strip()
1 change: 1 addition & 0 deletions edx_ace/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

RENDERERS = {
ChannelType.EMAIL: renderers.EmailRenderer(),
ChannelType.PUSH: renderers.PushNotificationRenderer(),
}


Expand Down
1 change: 1 addition & 0 deletions edx_ace/push_notifications/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from push_notifications.api.rest_framework import GCMDeviceViewSet
18 changes: 18 additions & 0 deletions edx_ace/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,21 @@ class EmailRenderer(AbstractRenderer):
A renderer for :attr:`.ChannelType.EMAIL` channels.
"""
rendered_message_cls = RenderedEmail


@attr.s
class RenderedPushNotification:
"""
Encapsulates all values needed to send a :class:`.Message`
over an :attr:`.ChannelType.PUSH`.
"""

title = attr.ib()
body = attr.ib()


class PushNotificationRenderer(AbstractRenderer):
"""
A renderer for :attr:`.ChannelType.PUSH` channels.
"""
rendered_message_cls = RenderedPushNotification
6 changes: 5 additions & 1 deletion edx_ace/tests/channel/test_channel_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from edx_ace.channel import ChannelMap, ChannelType, get_channel_for_message
from edx_ace.channel.braze import BrazeEmailChannel
from edx_ace.channel.file import FileEmailChannel
from edx_ace.channel.push_notification import PushNotificationChannel
from edx_ace.errors import UnsupportedChannelError
from edx_ace.message import Message
from edx_ace.recipient import Recipient
Expand Down Expand Up @@ -39,11 +40,13 @@ def setUp(self):
},
ACE_CHANNEL_DEFAULT_EMAIL='braze_email',
ACE_CHANNEL_TRANSACTIONAL_EMAIL='file_email',
ACE_CHANNEL_DEFAULT_PUSH='push_notification',
)
def test_get_channel_for_message(self):
channel_map = ChannelMap([
['file_email', FileEmailChannel()],
['braze_email', BrazeEmailChannel()],
['push_notifications', PushNotificationChannel()],
])

transactional_msg = Message(options={'transactional': True}, **self.msg_kwargs)
Expand All @@ -57,9 +60,10 @@ def test_get_channel_for_message(self):
assert isinstance(get_channel_for_message(ChannelType.EMAIL, transactional_msg), FileEmailChannel)
assert isinstance(get_channel_for_message(ChannelType.EMAIL, transactional_campaign_msg), BrazeEmailChannel)
assert isinstance(get_channel_for_message(ChannelType.EMAIL, info_msg), BrazeEmailChannel)
assert isinstance(get_channel_for_message(ChannelType.PUSH, info_msg), PushNotificationChannel)

with self.assertRaises(UnsupportedChannelError):
assert get_channel_for_message(ChannelType.PUSH, transactional_msg)
assert get_channel_for_message('unsupported_channel_type', transactional_msg)

@override_settings(
ACE_CHANNEL_DEFAULT_EMAIL='braze_email',
Expand Down
Loading

0 comments on commit 0c11505

Please sign in to comment.