edx-ace 1.9.1__tar.gz → 1.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {edx-ace-1.9.1/edx_ace.egg-info → edx-ace-1.10.0}/PKG-INFO +2 -1
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/__init__.py +1 -1
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/ace.py +20 -1
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/__init__.py +22 -17
- edx-ace-1.10.0/edx_ace/channel/push_notification.py +110 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/presentation.py +1 -0
- edx-ace-1.10.0/edx_ace/push_notifications/views/__init__.py +1 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/renderers.py +18 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/channel/test_channel_helpers.py +5 -1
- edx-ace-1.10.0/edx_ace/tests/channel/test_push_notification.py +173 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_ace.py +34 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_presentation.py +1 -2
- {edx-ace-1.9.1 → edx-ace-1.10.0/edx_ace.egg-info}/PKG-INFO +2 -1
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/SOURCES.txt +3 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/entry_points.txt +1 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/requires.txt +5 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/requirements/base.in +3 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/setup.py +3 -1
- {edx-ace-1.9.1 → edx-ace-1.10.0}/CHANGELOG.rst +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/LICENSE.txt +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/MANIFEST.in +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/README.rst +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/apps.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/braze.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/django_email.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/file.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/mixins.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/channel/sailthru.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/delivery.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/errors.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/message.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/monitoring.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/policy.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/recipient.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/recipient_resolver.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/serialization.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/templatetags/acetags.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/test_utils/__init__.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/channel/test_braze.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/channel/test_django_email.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/channel/test_file_email.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/channel/test_sailthru.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_date.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_delivery.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_message.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_policy.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/body.html +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/head.html +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/utils/__init__.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/utils/date.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/utils/once.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace/utils/plugins.py +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/dependency_links.txt +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/not-zip-safe +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/edx_ace.egg-info/top_level.txt +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/requirements/constraints.txt +0 -0
- {edx-ace-1.9.1 → edx-ace-1.10.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: edx-ace
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.10.0
|
4
4
|
Summary: Framework for Messaging
|
5
5
|
Home-page: https://github.com/openedx/edx-ace
|
6
6
|
Author: edX
|
@@ -313,3 +313,4 @@ Classifier: Programming Language :: Python :: 3.8
|
|
313
313
|
Classifier: Programming Language :: Python :: 3.12
|
314
314
|
Description-Content-Type: text/x-rst
|
315
315
|
Provides-Extra: sailthru
|
316
|
+
Provides-Extra: push_notifications
|
@@ -19,12 +19,18 @@ Usage:
|
|
19
19
|
)
|
20
20
|
ace.send(msg)
|
21
21
|
"""
|
22
|
+
import logging
|
23
|
+
|
24
|
+
from django.template import TemplateDoesNotExist
|
25
|
+
|
22
26
|
from edx_ace import delivery, policy, presentation
|
23
27
|
from edx_ace.channel import get_channel_for_message
|
24
28
|
from edx_ace.errors import ChannelError, UnsupportedChannelError
|
25
29
|
|
30
|
+
log = logging.getLogger(__name__)
|
31
|
+
|
26
32
|
|
27
|
-
def send(msg):
|
33
|
+
def send(msg, limit_to_channels=None):
|
28
34
|
"""
|
29
35
|
Send a message to a recipient.
|
30
36
|
|
@@ -37,12 +43,17 @@ def send(msg):
|
|
37
43
|
|
38
44
|
Args:
|
39
45
|
msg (Message): The message to send.
|
46
|
+
limit_to_channels (list of ChannelType, optional): If provided, only send the message over the specified
|
47
|
+
channels. If not provided, the message will be sent over all channels that the policies allow.
|
40
48
|
"""
|
41
49
|
msg.report_basics()
|
42
50
|
|
43
51
|
channels_for_message = policy.channels_for(msg)
|
44
52
|
|
45
53
|
for channel_type in channels_for_message:
|
54
|
+
if limit_to_channels and channel_type not in limit_to_channels:
|
55
|
+
log.debug('Skipping channel %s', channel_type)
|
56
|
+
|
46
57
|
try:
|
47
58
|
channel = get_channel_for_message(channel_type, msg)
|
48
59
|
except UnsupportedChannelError:
|
@@ -50,6 +61,14 @@ def send(msg):
|
|
50
61
|
|
51
62
|
try:
|
52
63
|
rendered_message = presentation.render(channel, msg)
|
64
|
+
except TemplateDoesNotExist as error:
|
65
|
+
msg.report(
|
66
|
+
'template_error',
|
67
|
+
'Unable to send message because template not found\n' + str(error)
|
68
|
+
)
|
69
|
+
continue
|
70
|
+
|
71
|
+
try:
|
53
72
|
delivery.deliver(channel, rendered_message, msg)
|
54
73
|
except ChannelError as error:
|
55
74
|
msg.report(
|
@@ -78,6 +78,7 @@ class ChannelMap:
|
|
78
78
|
"""
|
79
79
|
A class that represents a channel map, usually as described in Django settings and `setup.py` files.
|
80
80
|
"""
|
81
|
+
|
81
82
|
def __init__(self, channels_list):
|
82
83
|
"""
|
83
84
|
Initialize a ChannelMap.
|
@@ -170,27 +171,31 @@ def get_channel_for_message(channel_type, message):
|
|
170
171
|
Channel: The selected channel object.
|
171
172
|
"""
|
172
173
|
channels_map = channels()
|
174
|
+
channel_names = []
|
173
175
|
|
174
176
|
if channel_type == ChannelType.EMAIL:
|
175
177
|
if message.options.get('transactional'):
|
176
178
|
channel_names = [settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL, settings.ACE_CHANNEL_DEFAULT_EMAIL]
|
177
179
|
else:
|
178
180
|
channel_names = [settings.ACE_CHANNEL_DEFAULT_EMAIL]
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
181
|
+
elif channel_type == ChannelType.PUSH:
|
182
|
+
channel_names = [settings.ACE_CHANNEL_DEFAULT_PUSH]
|
183
|
+
|
184
|
+
try:
|
185
|
+
possible_channels = [
|
186
|
+
channels_map.get_channel_by_name(channel_type, channel_name)
|
187
|
+
for channel_name in channel_names
|
188
|
+
]
|
189
|
+
except KeyError:
|
190
|
+
return channels_map.get_default_channel(channel_type)
|
191
|
+
|
192
|
+
# First see if any channel specifically demands to deliver this message
|
193
|
+
for channel in possible_channels:
|
194
|
+
if channel.overrides_delivery_for_message(message):
|
195
|
+
return channel
|
196
|
+
|
197
|
+
# Else the normal path: use the preferred channel for this message type
|
198
|
+
if possible_channels:
|
194
199
|
return possible_channels[0]
|
195
|
-
|
196
|
-
|
200
|
+
else:
|
201
|
+
return channels_map.get_default_channel(channel_type)
|
@@ -0,0 +1,110 @@
|
|
1
|
+
"""
|
2
|
+
Channel for sending push notifications.
|
3
|
+
"""
|
4
|
+
import logging
|
5
|
+
import re
|
6
|
+
|
7
|
+
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert
|
8
|
+
from push_notifications.gcm import dict_to_fcm_message, send_message
|
9
|
+
from push_notifications.models import GCMDevice
|
10
|
+
|
11
|
+
from django.conf import settings
|
12
|
+
|
13
|
+
from edx_ace.channel import Channel, ChannelType
|
14
|
+
from edx_ace.errors import FatalChannelDeliveryError
|
15
|
+
from edx_ace.message import Message
|
16
|
+
from edx_ace.renderers import RenderedPushNotification
|
17
|
+
|
18
|
+
LOG = logging.getLogger(__name__)
|
19
|
+
APNS_DEFAULT_PRIORITY = '5'
|
20
|
+
APNS_DEFAULT_PUSH_TYPE = 'alert'
|
21
|
+
|
22
|
+
|
23
|
+
class PushNotificationChannel(Channel):
|
24
|
+
"""
|
25
|
+
A channel for sending push notifications.
|
26
|
+
"""
|
27
|
+
|
28
|
+
channel_type = ChannelType.PUSH
|
29
|
+
|
30
|
+
@classmethod
|
31
|
+
def enabled(cls):
|
32
|
+
"""
|
33
|
+
Returns true if the push notification settings are configured.
|
34
|
+
"""
|
35
|
+
return bool(getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None))
|
36
|
+
|
37
|
+
def deliver(self, message: Message, rendered_message: RenderedPushNotification) -> None:
|
38
|
+
"""
|
39
|
+
Transmit a rendered message to a recipient.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
message: The message to transmit.
|
43
|
+
rendered_message: The rendered content of the message that has been personalized
|
44
|
+
for this particular recipient.
|
45
|
+
"""
|
46
|
+
device_tokens = self.get_user_device_tokens(message.recipient.lms_user_id)
|
47
|
+
if not device_tokens:
|
48
|
+
LOG.info(
|
49
|
+
'Recipient with ID %s has no push token. Skipping push notification.',
|
50
|
+
message.recipient.lms_user_id
|
51
|
+
)
|
52
|
+
return
|
53
|
+
|
54
|
+
for token in device_tokens:
|
55
|
+
self.send_message(message, token, rendered_message)
|
56
|
+
|
57
|
+
def send_message(self, message: Message, token: str, rendered_message: RenderedPushNotification) -> None:
|
58
|
+
"""
|
59
|
+
Send a push notification to a device by token.
|
60
|
+
"""
|
61
|
+
notification_data = {
|
62
|
+
'title': self.compress_spaces(rendered_message.title),
|
63
|
+
'body': self.compress_spaces(rendered_message.body),
|
64
|
+
'notification_key': token,
|
65
|
+
**message.context.get('push_notification_extra_context', {}),
|
66
|
+
}
|
67
|
+
message = dict_to_fcm_message(notification_data)
|
68
|
+
# Note: By default dict_to_fcm_message does not support APNS configuration,
|
69
|
+
# only Android configuration, so we need to collect and set it manually.
|
70
|
+
apns_config = self.collect_apns_config(notification_data)
|
71
|
+
message.apns = apns_config
|
72
|
+
try:
|
73
|
+
send_message(token, message, settings.FCM_APP_NAME)
|
74
|
+
except Exception as e:
|
75
|
+
LOG.exception('Failed to send push notification to %s', token)
|
76
|
+
raise FatalChannelDeliveryError(f'Failed to send push notification to {token}') from e
|
77
|
+
|
78
|
+
@staticmethod
|
79
|
+
def collect_apns_config(notification_data: dict) -> APNSConfig:
|
80
|
+
"""
|
81
|
+
Collect APNS configuration with payload for the push notification.
|
82
|
+
|
83
|
+
This APNSConfig must be set to notifications for Firebase to send push notifications to iOS devices.
|
84
|
+
Notification has default priority and visibility settings, described in Apple's documentation.
|
85
|
+
(https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns)
|
86
|
+
"""
|
87
|
+
apns_alert = ApsAlert(title=notification_data['title'], body=notification_data['body'])
|
88
|
+
aps = Aps(alert=apns_alert, sound='default')
|
89
|
+
return APNSConfig(
|
90
|
+
headers={'apns-priority': APNS_DEFAULT_PRIORITY, 'apns-push-type': APNS_DEFAULT_PUSH_TYPE},
|
91
|
+
payload=APNSPayload(aps)
|
92
|
+
)
|
93
|
+
|
94
|
+
@staticmethod
|
95
|
+
def get_user_device_tokens(user_id: int) -> list:
|
96
|
+
"""
|
97
|
+
Get the device tokens for a user.
|
98
|
+
"""
|
99
|
+
return list(GCMDevice.objects.filter(
|
100
|
+
user_id=user_id,
|
101
|
+
cloud_message_type='FCM',
|
102
|
+
active=True,
|
103
|
+
).values_list('registration_id', flat=True))
|
104
|
+
|
105
|
+
@staticmethod
|
106
|
+
def compress_spaces(html_str: str) -> str:
|
107
|
+
"""
|
108
|
+
Compress spaces and remove newlines to make it easier to author templates.
|
109
|
+
"""
|
110
|
+
return re.sub('\\s+', ' ', html_str, re.UNICODE).strip()
|
@@ -0,0 +1 @@
|
|
1
|
+
from push_notifications.api.rest_framework import GCMDeviceViewSet
|
@@ -81,3 +81,21 @@ class EmailRenderer(AbstractRenderer):
|
|
81
81
|
A renderer for :attr:`.ChannelType.EMAIL` channels.
|
82
82
|
"""
|
83
83
|
rendered_message_cls = RenderedEmail
|
84
|
+
|
85
|
+
|
86
|
+
@attr.s
|
87
|
+
class RenderedPushNotification:
|
88
|
+
"""
|
89
|
+
Encapsulates all values needed to send a :class:`.Message`
|
90
|
+
over an :attr:`.ChannelType.PUSH`.
|
91
|
+
"""
|
92
|
+
|
93
|
+
title = attr.ib()
|
94
|
+
body = attr.ib()
|
95
|
+
|
96
|
+
|
97
|
+
class PushNotificationRenderer(AbstractRenderer):
|
98
|
+
"""
|
99
|
+
A renderer for :attr:`.ChannelType.PUSH` channels.
|
100
|
+
"""
|
101
|
+
rendered_message_cls = RenderedPushNotification
|
@@ -8,6 +8,7 @@ from django.test import TestCase, override_settings
|
|
8
8
|
from edx_ace.channel import ChannelMap, ChannelType, get_channel_for_message
|
9
9
|
from edx_ace.channel.braze import BrazeEmailChannel
|
10
10
|
from edx_ace.channel.file import FileEmailChannel
|
11
|
+
from edx_ace.channel.push_notification import PushNotificationChannel
|
11
12
|
from edx_ace.errors import UnsupportedChannelError
|
12
13
|
from edx_ace.message import Message
|
13
14
|
from edx_ace.recipient import Recipient
|
@@ -39,11 +40,13 @@ class TestChannelMap(TestCase):
|
|
39
40
|
},
|
40
41
|
ACE_CHANNEL_DEFAULT_EMAIL='braze_email',
|
41
42
|
ACE_CHANNEL_TRANSACTIONAL_EMAIL='file_email',
|
43
|
+
ACE_CHANNEL_DEFAULT_PUSH='push_notification',
|
42
44
|
)
|
43
45
|
def test_get_channel_for_message(self):
|
44
46
|
channel_map = ChannelMap([
|
45
47
|
['file_email', FileEmailChannel()],
|
46
48
|
['braze_email', BrazeEmailChannel()],
|
49
|
+
['push_notifications', PushNotificationChannel()],
|
47
50
|
])
|
48
51
|
|
49
52
|
transactional_msg = Message(options={'transactional': True}, **self.msg_kwargs)
|
@@ -57,9 +60,10 @@ class TestChannelMap(TestCase):
|
|
57
60
|
assert isinstance(get_channel_for_message(ChannelType.EMAIL, transactional_msg), FileEmailChannel)
|
58
61
|
assert isinstance(get_channel_for_message(ChannelType.EMAIL, transactional_campaign_msg), BrazeEmailChannel)
|
59
62
|
assert isinstance(get_channel_for_message(ChannelType.EMAIL, info_msg), BrazeEmailChannel)
|
63
|
+
assert isinstance(get_channel_for_message(ChannelType.PUSH, info_msg), PushNotificationChannel)
|
60
64
|
|
61
65
|
with self.assertRaises(UnsupportedChannelError):
|
62
|
-
assert get_channel_for_message(
|
66
|
+
assert get_channel_for_message('unsupported_channel_type', transactional_msg)
|
63
67
|
|
64
68
|
@override_settings(
|
65
69
|
ACE_CHANNEL_DEFAULT_EMAIL='braze_email',
|
@@ -0,0 +1,173 @@
|
|
1
|
+
"""
|
2
|
+
Tests for the PushNotificationChannel class.
|
3
|
+
"""
|
4
|
+
from unittest import TestCase
|
5
|
+
from unittest.mock import MagicMock, patch
|
6
|
+
|
7
|
+
import pytest
|
8
|
+
from firebase_admin.messaging import APNSConfig
|
9
|
+
from push_notifications.models import GCMDevice
|
10
|
+
|
11
|
+
from django.contrib.auth import get_user_model
|
12
|
+
from django.test import override_settings
|
13
|
+
|
14
|
+
from edx_ace.channel.push_notification import PushNotificationChannel
|
15
|
+
from edx_ace.errors import FatalChannelDeliveryError
|
16
|
+
from edx_ace.message import Message
|
17
|
+
from edx_ace.recipient import Recipient
|
18
|
+
from edx_ace.renderers import RenderedPushNotification
|
19
|
+
from edx_ace.utils.date import get_current_time
|
20
|
+
|
21
|
+
User = get_user_model()
|
22
|
+
|
23
|
+
|
24
|
+
@pytest.mark.django_db
|
25
|
+
class TestPushNotificationChannel(TestCase):
|
26
|
+
"""
|
27
|
+
Tests for the PushNotificationChannel class.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def setUp(self):
|
31
|
+
super().setUp()
|
32
|
+
self.user = User.objects.create(username='username', email='email@example.com')
|
33
|
+
self.lms_user_id = self.user.id
|
34
|
+
self.msg_kwargs = {
|
35
|
+
'app_label': 'test_app_label',
|
36
|
+
'name': 'test_message',
|
37
|
+
'expiration_time': get_current_time(),
|
38
|
+
'context': {
|
39
|
+
'key1': 'value1',
|
40
|
+
'key2': 'value2',
|
41
|
+
},
|
42
|
+
'recipient': Recipient(
|
43
|
+
lms_user_id=self.lms_user_id,
|
44
|
+
)
|
45
|
+
}
|
46
|
+
|
47
|
+
@override_settings(PUSH_NOTIFICATIONS_SETTINGS={'CONFIG': 'push_notifications.conf.AppConfig'})
|
48
|
+
def test_enabled(self):
|
49
|
+
"""
|
50
|
+
Test that the channel is enabled when the settings are configured.
|
51
|
+
"""
|
52
|
+
assert PushNotificationChannel.enabled()
|
53
|
+
|
54
|
+
@override_settings(PUSH_NOTIFICATIONS_SETTINGS=None)
|
55
|
+
def test_disabled(self):
|
56
|
+
"""
|
57
|
+
Test that the channel is disabled when the settings are not configured.
|
58
|
+
"""
|
59
|
+
assert not PushNotificationChannel.enabled()
|
60
|
+
|
61
|
+
@patch('edx_ace.channel.push_notification.LOG')
|
62
|
+
@patch('edx_ace.channel.push_notification.PushNotificationChannel.send_message')
|
63
|
+
def test_deliver_no_device_tokens(self, mock_send_message, mock_log):
|
64
|
+
"""
|
65
|
+
Test that the deliver method logs a message when the recipient has no push tokens.
|
66
|
+
"""
|
67
|
+
with patch.object(PushNotificationChannel, 'get_user_device_tokens', return_value=[]):
|
68
|
+
message = Message(options={}, **self.msg_kwargs)
|
69
|
+
rendered_message = RenderedPushNotification(title='Test', body='This is a test.')
|
70
|
+
|
71
|
+
channel = PushNotificationChannel()
|
72
|
+
channel.deliver(message, rendered_message)
|
73
|
+
|
74
|
+
mock_log.info.assert_called_with(
|
75
|
+
'Recipient with ID %s has no push token. Skipping push notification.',
|
76
|
+
self.lms_user_id
|
77
|
+
)
|
78
|
+
mock_send_message.assert_not_called()
|
79
|
+
|
80
|
+
@patch('edx_ace.channel.push_notification.PushNotificationChannel.send_message')
|
81
|
+
def test_deliver_with_device_tokens(self, mock_send_message):
|
82
|
+
"""
|
83
|
+
Test that the deliver method sends a push notification for each device token.
|
84
|
+
"""
|
85
|
+
with patch.object(PushNotificationChannel, 'get_user_device_tokens', return_value=['token1', 'token2']):
|
86
|
+
message = Message(options={}, **self.msg_kwargs)
|
87
|
+
rendered_message = RenderedPushNotification(title='Test', body='This is a test.')
|
88
|
+
|
89
|
+
channel = PushNotificationChannel()
|
90
|
+
channel.deliver(message, rendered_message)
|
91
|
+
|
92
|
+
assert mock_send_message.call_count == 2
|
93
|
+
|
94
|
+
@override_settings(FCM_APP_NAME='test_app')
|
95
|
+
@patch('edx_ace.channel.push_notification.send_message')
|
96
|
+
@patch('edx_ace.channel.push_notification.dict_to_fcm_message')
|
97
|
+
@patch('edx_ace.channel.push_notification.PushNotificationChannel.collect_apns_config')
|
98
|
+
def test_send_message_success(self, mock_collect_apns_config, mock_dict_to_fcm_message, mock_send_message):
|
99
|
+
"""
|
100
|
+
Test that the send_message method sends a push notification successfully.
|
101
|
+
"""
|
102
|
+
mock_dict_to_fcm_message.return_value = MagicMock()
|
103
|
+
mock_collect_apns_config.return_value = MagicMock()
|
104
|
+
|
105
|
+
message = Message(options={}, **self.msg_kwargs)
|
106
|
+
rendered_message = RenderedPushNotification(title='Test', body='This is a test.')
|
107
|
+
|
108
|
+
channel = PushNotificationChannel()
|
109
|
+
channel.send_message(message, 'token', rendered_message)
|
110
|
+
|
111
|
+
mock_send_message.assert_called_once()
|
112
|
+
|
113
|
+
@override_settings(FCM_APP_NAME='test_app')
|
114
|
+
@patch('edx_ace.channel.push_notification.send_message', side_effect=FatalChannelDeliveryError('Error'))
|
115
|
+
@patch('edx_ace.channel.push_notification.dict_to_fcm_message')
|
116
|
+
@patch('edx_ace.channel.push_notification.PushNotificationChannel.collect_apns_config')
|
117
|
+
@patch('edx_ace.channel.push_notification.LOG')
|
118
|
+
def test_send_message_failure(
|
119
|
+
self, mock_log, mock_collect_apns_config, mock_dict_to_fcm_message, mock_send_message
|
120
|
+
):
|
121
|
+
"""
|
122
|
+
Test that the send_message method logs an exception when an error occurs while sending the message.
|
123
|
+
"""
|
124
|
+
mock_dict_to_fcm_message.return_value = MagicMock()
|
125
|
+
mock_collect_apns_config.return_value = MagicMock()
|
126
|
+
|
127
|
+
message = Message(options={}, **self.msg_kwargs)
|
128
|
+
rendered_message = RenderedPushNotification(title='Test', body='This is a test.')
|
129
|
+
|
130
|
+
channel = PushNotificationChannel()
|
131
|
+
|
132
|
+
with pytest.raises(FatalChannelDeliveryError):
|
133
|
+
channel.send_message(message, 'token', rendered_message)
|
134
|
+
|
135
|
+
mock_send_message.assert_called_with('token', mock_dict_to_fcm_message.return_value, 'test_app')
|
136
|
+
mock_log.exception.assert_called_with('Failed to send push notification to %s', 'token')
|
137
|
+
|
138
|
+
def test_collect_apns_config(self):
|
139
|
+
"""
|
140
|
+
Test that the collect_apns_config method returns an APNSConfig object with the correct headers.
|
141
|
+
"""
|
142
|
+
notification_data = {'title': 'Test Title', 'body': 'Test Body'}
|
143
|
+
|
144
|
+
apns_config = PushNotificationChannel.collect_apns_config(notification_data)
|
145
|
+
|
146
|
+
assert isinstance(apns_config, APNSConfig)
|
147
|
+
assert apns_config.headers['apns-priority'] == '5'
|
148
|
+
assert apns_config.headers['apns-push-type'] == 'alert'
|
149
|
+
|
150
|
+
def test_compress_spaces(self):
|
151
|
+
"""
|
152
|
+
Test that the compress_spaces method removes extra spaces and newlines from a string.
|
153
|
+
"""
|
154
|
+
compressed = PushNotificationChannel.compress_spaces('This is a \n\n test.')
|
155
|
+
assert compressed == 'This is a test.'
|
156
|
+
|
157
|
+
def test_get_user_device_tokens(self):
|
158
|
+
"""
|
159
|
+
Test that the get_user_device_tokens method returns the device tokens for a user.
|
160
|
+
"""
|
161
|
+
gcm_device = GCMDevice.objects.create(user=self.user, registration_id='token1')
|
162
|
+
|
163
|
+
channel = PushNotificationChannel()
|
164
|
+
tokens = channel.get_user_device_tokens(self.lms_user_id)
|
165
|
+
assert tokens == [gcm_device.registration_id]
|
166
|
+
|
167
|
+
def test_get_user_device_tokens_no_tokens(self):
|
168
|
+
"""
|
169
|
+
Test that the get_user_device_tokens method returns an empty list when the user has no device tokens.
|
170
|
+
"""
|
171
|
+
channel = PushNotificationChannel()
|
172
|
+
tokens = channel.get_user_device_tokens(self.lms_user_id)
|
173
|
+
assert tokens == []
|
@@ -3,6 +3,7 @@ Tests of :mod:`edx_ace.ace`.
|
|
3
3
|
"""
|
4
4
|
from unittest.mock import Mock, patch
|
5
5
|
|
6
|
+
from django.template import TemplateDoesNotExist
|
6
7
|
from django.test import TestCase
|
7
8
|
|
8
9
|
from edx_ace import ace
|
@@ -63,3 +64,36 @@ class TestAce(TestCase):
|
|
63
64
|
)
|
64
65
|
|
65
66
|
ace.send(msg) # UnsupportedChannelError shouldn't throw UnsupportedChannelError
|
67
|
+
|
68
|
+
@patch('edx_ace.ace.log')
|
69
|
+
@patch('edx_ace.ace.policy.channels_for', return_value=[ChannelType.PUSH])
|
70
|
+
def test_ace_send_skip_limited_channel(self, mock_channels_for, mock_log):
|
71
|
+
recipient = Recipient(lms_user_id=123)
|
72
|
+
msg = Message(
|
73
|
+
app_label='testapp',
|
74
|
+
name='testmessage',
|
75
|
+
recipient=recipient,
|
76
|
+
)
|
77
|
+
|
78
|
+
ace.send(msg, limit_to_channels=[ChannelType.EMAIL])
|
79
|
+
|
80
|
+
mock_channels_for.assert_called_once_with(msg)
|
81
|
+
mock_log.debug.assert_called_once_with('Skipping channel %s', ChannelType.PUSH)
|
82
|
+
|
83
|
+
@patch('edx_ace.ace.presentation.render', side_effect=TemplateDoesNotExist('template not found'))
|
84
|
+
def test_ace_send_template_does_not_exists(self, *_args):
|
85
|
+
recipient = Recipient(lms_user_id=123)
|
86
|
+
report_mock = Mock()
|
87
|
+
|
88
|
+
msg = Mock(
|
89
|
+
app_label='testapp',
|
90
|
+
name='testmessage',
|
91
|
+
recipient=recipient,
|
92
|
+
context={},
|
93
|
+
report=report_mock,
|
94
|
+
)
|
95
|
+
ace.send(msg)
|
96
|
+
report_mock.assert_called_with(
|
97
|
+
'template_error',
|
98
|
+
'Unable to send message because template not found\ntemplate not found'
|
99
|
+
)
|
@@ -4,7 +4,6 @@ Tests of :mod:`edx_ace.presentation`.
|
|
4
4
|
from unittest import TestCase
|
5
5
|
from unittest.mock import Mock
|
6
6
|
|
7
|
-
from edx_ace.channel import ChannelType
|
8
7
|
from edx_ace.errors import UnsupportedChannelError
|
9
8
|
from edx_ace.presentation import render
|
10
9
|
|
@@ -16,7 +15,7 @@ class TestRender(TestCase):
|
|
16
15
|
|
17
16
|
def test_missing_renderer(self):
|
18
17
|
channel = Mock(
|
19
|
-
channel_type=
|
18
|
+
channel_type='unsupported_channel_type'
|
20
19
|
)
|
21
20
|
|
22
21
|
message = Mock()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: edx-ace
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.10.0
|
4
4
|
Summary: Framework for Messaging
|
5
5
|
Home-page: https://github.com/openedx/edx-ace
|
6
6
|
Author: edX
|
@@ -313,3 +313,4 @@ Classifier: Programming Language :: Python :: 3.8
|
|
313
313
|
Classifier: Programming Language :: Python :: 3.12
|
314
314
|
Description-Content-Type: text/x-rst
|
315
315
|
Provides-Extra: sailthru
|
316
|
+
Provides-Extra: push_notifications
|
@@ -29,7 +29,9 @@ edx_ace/channel/braze.py
|
|
29
29
|
edx_ace/channel/django_email.py
|
30
30
|
edx_ace/channel/file.py
|
31
31
|
edx_ace/channel/mixins.py
|
32
|
+
edx_ace/channel/push_notification.py
|
32
33
|
edx_ace/channel/sailthru.py
|
34
|
+
edx_ace/push_notifications/views/__init__.py
|
33
35
|
edx_ace/templatetags/acetags.py
|
34
36
|
edx_ace/test_utils/__init__.py
|
35
37
|
edx_ace/tests/test_ace.py
|
@@ -42,6 +44,7 @@ edx_ace/tests/channel/test_braze.py
|
|
42
44
|
edx_ace/tests/channel/test_channel_helpers.py
|
43
45
|
edx_ace/tests/channel/test_django_email.py
|
44
46
|
edx_ace/tests/channel/test_file_email.py
|
47
|
+
edx_ace/tests/channel/test_push_notification.py
|
45
48
|
edx_ace/tests/channel/test_sailthru.py
|
46
49
|
edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/body.html
|
47
50
|
edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/head.html
|
@@ -2,5 +2,6 @@
|
|
2
2
|
braze_email = edx_ace.channel.braze:BrazeEmailChannel
|
3
3
|
django_email = edx_ace.channel.django_email:DjangoEmailChannel
|
4
4
|
file_email = edx_ace.channel.file:FileEmailChannel
|
5
|
+
push_notification = edx_ace.channel.push_notification:PushNotificationChannel
|
5
6
|
sailthru_email = edx_ace.channel.sailthru:SailthruEmailChannel
|
6
7
|
|
@@ -1,10 +1,15 @@
|
|
1
1
|
Django>=2.2
|
2
2
|
attrs>=17.2.0
|
3
|
+
django-push-notifications
|
3
4
|
edx-django-utils>=5.14.2
|
5
|
+
firebase-admin
|
4
6
|
python-dateutil
|
5
7
|
sailthru-client==2.2.3
|
6
8
|
six
|
7
9
|
stevedore>=1.10.0
|
8
10
|
|
11
|
+
[push_notifications]
|
12
|
+
django-push-notifications[fcm]
|
13
|
+
|
9
14
|
[sailthru]
|
10
15
|
sailthru-client<2.3,>2.2
|
@@ -112,7 +112,8 @@ setup(
|
|
112
112
|
include_package_data=True,
|
113
113
|
install_requires=load_requirements('requirements/base.in'),
|
114
114
|
extras_require={
|
115
|
-
'sailthru': ["sailthru-client>2.2,<2.3"]
|
115
|
+
'sailthru': ["sailthru-client>2.2,<2.3"],
|
116
|
+
'push_notifications': ["django-push-notifications[FCM]"]
|
116
117
|
},
|
117
118
|
license="AGPL 3.0",
|
118
119
|
zip_safe=False,
|
@@ -134,6 +135,7 @@ setup(
|
|
134
135
|
'sailthru_email = edx_ace.channel.sailthru:SailthruEmailChannel',
|
135
136
|
'file_email = edx_ace.channel.file:FileEmailChannel',
|
136
137
|
'django_email = edx_ace.channel.django_email:DjangoEmailChannel',
|
138
|
+
'push_notification = edx_ace.channel.push_notification:PushNotificationChannel',
|
137
139
|
]
|
138
140
|
}
|
139
141
|
)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|