Skip to content

Commit

Permalink
feat: [FC-0047] add functionality to send push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
NiedielnitsevIvan committed Jun 11, 2024
1 parent 3c66e8e commit f6e8b7b
Show file tree
Hide file tree
Showing 16 changed files with 569 additions and 205 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.8.0'
__version__ = '1.9.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,14 @@
)
ace.send(msg)
"""
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


def send(msg):
def send(msg, limit_to_channels=None):
"""
Send a message to a recipient.
Expand All @@ -37,19 +39,36 @@ 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:
msg.report(
'channel_skipped',
f'Skipping channel {channel_type}'
)
continue

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',
str(error)
)
continue

try:
delivery.deliver(channel, rendered_message, msg)
except ChannelError as error:
msg.report(
Expand Down
37 changes: 20 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,29 @@ 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
return possible_channels[0]

return channels_map.get_default_channel(channel_type)
if 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
return possible_channels[0] if possible_channels else channels_map.get_default_channel(channel_type)
107 changes: 107 additions & 0 deletions edx_ace/channel/push_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
Channel for sending push notifications.
"""
import logging
import re

from django.conf import settings

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

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 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(f'Recipient {message.recipient.email_address} has no push token. Skipping push notification.')
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.sanitize_html(rendered_message.subject),
'body': self.sanitize_html(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(f'Failed to send push notification to {token}')
raise FatalChannelDeliveryError(f'Failed to send push notification to {token}')

@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 sanitize_html(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`.
"""

subject = attr.ib()
body = attr.ib()


class PushNotificationRenderer(AbstractRenderer):
"""
A renderer for :attr:`.ChannelType.PUSH` channels.
"""
rendered_message_cls = RenderedPushNotification
4 changes: 4 additions & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ attrs>=17.2.0 # Attributes without boilerplate
sailthru-client==2.2.3
six
stevedore>=1.10.0

firebase-admin==6.5.0

-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications
18 changes: 7 additions & 11 deletions requirements/ci.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.8
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# make upgrade
Expand All @@ -12,27 +12,23 @@ colorama==0.4.6
# via tox
distlib==0.3.8
# via virtualenv
filelock==3.13.1
filelock==3.14.0
# via
# tox
# virtualenv
packaging==23.2
packaging==24.0
# via
# pyproject-api
# tox
platformdirs==4.2.0
platformdirs==4.2.2
# via
# tox
# virtualenv
pluggy==1.4.0
pluggy==1.5.0
# via tox
pyproject-api==1.6.1
# via tox
tomli==2.0.1
# via
# pyproject-api
# tox
tox==4.14.0
tox==4.15.0
# via -r requirements/ci.in
virtualenv==20.25.1
virtualenv==20.26.2
# via tox
Loading

0 comments on commit f6e8b7b

Please sign in to comment.