edx-ace 1.9.1__py2.py3-none-any.whl → 1.10.1__py2.py3-none-any.whl
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/__init__.py +1 -1
- edx_ace/ace.py +20 -1
- edx_ace/channel/__init__.py +25 -18
- edx_ace/channel/push_notification.py +110 -0
- edx_ace/presentation.py +1 -0
- edx_ace/push_notifications/views/__init__.py +1 -0
- edx_ace/renderers.py +18 -0
- edx_ace/tests/channel/test_channel_helpers.py +5 -1
- edx_ace/tests/channel/test_push_notification.py +173 -0
- edx_ace/tests/test_ace.py +34 -0
- edx_ace/tests/test_presentation.py +1 -2
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/METADATA +5 -1
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/RECORD +17 -14
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/entry_points.txt +1 -0
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/LICENSE.txt +0 -0
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/WHEEL +0 -0
- {edx_ace-1.9.1.dist-info → edx_ace-1.10.1.dist-info}/top_level.txt +0 -0
edx_ace/__init__.py
CHANGED
edx_ace/ace.py
CHANGED
@@ -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(
|
edx_ace/channel/__init__.py
CHANGED
@@ -5,6 +5,7 @@ to add new delivery :class:`Channel` instances to an ACE application.
|
|
5
5
|
Developers wanting to add a new deliver channel should subclass :class:`Channel`,
|
6
6
|
and then add an entry to the ``openedx.ace.channel`` entrypoint in their ``setup.py``.
|
7
7
|
"""
|
8
|
+
|
8
9
|
import abc
|
9
10
|
import itertools
|
10
11
|
from collections import OrderedDict, defaultdict
|
@@ -24,6 +25,7 @@ class ChannelType(Enum):
|
|
24
25
|
"""
|
25
26
|
All supported communication channels.
|
26
27
|
"""
|
28
|
+
|
27
29
|
EMAIL = 'email'
|
28
30
|
PUSH = 'push'
|
29
31
|
|
@@ -78,6 +80,7 @@ class ChannelMap:
|
|
78
80
|
"""
|
79
81
|
A class that represents a channel map, usually as described in Django settings and `setup.py` files.
|
80
82
|
"""
|
83
|
+
|
81
84
|
def __init__(self, channels_list):
|
82
85
|
"""
|
83
86
|
Initialize a ChannelMap.
|
@@ -126,7 +129,7 @@ class ChannelMap:
|
|
126
129
|
except (StopIteration, KeyError) as error:
|
127
130
|
raise UnsupportedChannelError(
|
128
131
|
f'No implementation for channel {channel_type} is registered. '
|
129
|
-
f'Available channels are: {channels()}'
|
132
|
+
f'Available channels are: {channels()}',
|
130
133
|
) from error
|
131
134
|
|
132
135
|
def __str__(self):
|
@@ -170,27 +173,31 @@ def get_channel_for_message(channel_type, message):
|
|
170
173
|
Channel: The selected channel object.
|
171
174
|
"""
|
172
175
|
channels_map = channels()
|
176
|
+
channel_names = []
|
173
177
|
|
174
178
|
if channel_type == ChannelType.EMAIL:
|
175
179
|
if message.options.get('transactional'):
|
176
180
|
channel_names = [settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL, settings.ACE_CHANNEL_DEFAULT_EMAIL]
|
177
181
|
else:
|
178
182
|
channel_names = [settings.ACE_CHANNEL_DEFAULT_EMAIL]
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
183
|
+
elif channel_type == ChannelType.PUSH and getattr(settings, "ACE_CHANNEL_DEFAULT_PUSH", None):
|
184
|
+
channel_names = [settings.ACE_CHANNEL_DEFAULT_PUSH]
|
185
|
+
|
186
|
+
try:
|
187
|
+
possible_channels = [
|
188
|
+
channels_map.get_channel_by_name(channel_type, channel_name)
|
189
|
+
for channel_name in channel_names
|
190
|
+
]
|
191
|
+
except KeyError:
|
192
|
+
return channels_map.get_default_channel(channel_type)
|
193
|
+
|
194
|
+
# First see if any channel specifically demands to deliver this message
|
195
|
+
for channel in possible_channels:
|
196
|
+
if channel.overrides_delivery_for_message(message):
|
197
|
+
return channel
|
198
|
+
|
199
|
+
# Else the normal path: use the preferred channel for this message type
|
200
|
+
if possible_channels:
|
194
201
|
return possible_channels[0]
|
195
|
-
|
196
|
-
|
202
|
+
else:
|
203
|
+
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()
|
edx_ace/presentation.py
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
from push_notifications.api.rest_framework import GCMDeviceViewSet
|
edx_ace/renderers.py
CHANGED
@@ -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 == []
|
edx_ace/tests/test_ace.py
CHANGED
@@ -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.1
|
4
4
|
Summary: Framework for Messaging
|
5
5
|
Home-page: https://github.com/openedx/edx-ace
|
6
6
|
Author: edX
|
@@ -20,11 +20,15 @@ Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Description-Content-Type: text/x-rst
|
21
21
|
Requires-Dist: Django >=2.2
|
22
22
|
Requires-Dist: attrs >=17.2.0
|
23
|
+
Requires-Dist: django-push-notifications
|
23
24
|
Requires-Dist: edx-django-utils >=5.14.2
|
25
|
+
Requires-Dist: firebase-admin
|
24
26
|
Requires-Dist: python-dateutil
|
25
27
|
Requires-Dist: sailthru-client ==2.2.3
|
26
28
|
Requires-Dist: six
|
27
29
|
Requires-Dist: stevedore >=1.10.0
|
30
|
+
Provides-Extra: push_notifications
|
31
|
+
Requires-Dist: django-push-notifications[fcm] ; extra == 'push_notifications'
|
28
32
|
Provides-Extra: sailthru
|
29
33
|
Requires-Dist: sailthru-client <2.3,>2.2 ; extra == 'sailthru'
|
30
34
|
|
@@ -1,34 +1,37 @@
|
|
1
|
-
edx_ace/__init__.py,sha256=
|
2
|
-
edx_ace/ace.py,sha256=
|
1
|
+
edx_ace/__init__.py,sha256=BEI_2W_-VD3LcjFTXuez9y9Mk05167aQI7kLCvVbvFs,636
|
2
|
+
edx_ace/ace.py,sha256=0nP8zvIWiME4qmpMT1PaV4nRDG6YR9Dxm7A6FGoBa8I,2501
|
3
3
|
edx_ace/apps.py,sha256=xNedkdW6TNpm-W1uxnj3Vre2R1akkh2n_7DkSfKXmAk,216
|
4
4
|
edx_ace/delivery.py,sha256=Tjc3GKubPaDsbKiBXO2jQBz9ursJ3nb7ZgZNSEeuqWQ,2690
|
5
5
|
edx_ace/errors.py,sha256=y0MqT55qXLkpn_r5LVhHcYuU2-zHs56CQTAbNCqb72k,1065
|
6
6
|
edx_ace/message.py,sha256=lhmPor9vnaLvC4NPyRgB-obpGjGv9Lni0obcOUTgyCg,8340
|
7
7
|
edx_ace/monitoring.py,sha256=6nEcAJMCr9keCTJw9JjGecTg_J1ubcGJfuGiFq2B8G4,257
|
8
8
|
edx_ace/policy.py,sha256=Zi0oupnmvgnW7jOkEpgsOd-gj6isR-3Mna2SxsdG4U8,2915
|
9
|
-
edx_ace/presentation.py,sha256=
|
9
|
+
edx_ace/presentation.py,sha256=XCQwPsl_2LZMcyF_aFX7b91d7rEzmQJiv8XgOSCyLQ0,838
|
10
10
|
edx_ace/recipient.py,sha256=ogZjjKorAc6yiqvcnXcAMsHz2uAfhYU5qLPUoifofXQ,576
|
11
11
|
edx_ace/recipient_resolver.py,sha256=ChY0cgLSt_HioKSHyuCh7iHSJOsBWuivsOvAc6QyedE,959
|
12
|
-
edx_ace/renderers.py,sha256=
|
12
|
+
edx_ace/renderers.py,sha256=eJcTWyRhny-3PLHDV4Rozk1TJ0fjsqgOcFOWO_TTngk,3143
|
13
13
|
edx_ace/serialization.py,sha256=EptnQqbI9j5kVqdUDAlm2pcm3dUsFwsP9tAji3y5uqc,3775
|
14
|
-
edx_ace/channel/__init__.py,sha256=
|
14
|
+
edx_ace/channel/__init__.py,sha256=l2qZdiQb2HFsMixWjqf50-si7aGr06M2MvEYc7XayjU,6890
|
15
15
|
edx_ace/channel/braze.py,sha256=k-R9jZpiBQTV0FQ3Z2gOipVgxNpBMPSLBL71muh9go4,10240
|
16
16
|
edx_ace/channel/django_email.py,sha256=9TNdiFJ2U3QjKEriUl3_9twAFkH-E2EmrWujStUOWN0,2174
|
17
17
|
edx_ace/channel/file.py,sha256=_Jx8o5Cw8IpEdaF7g-ljF7CEqswBHmuwyupm_tkI2wk,3028
|
18
18
|
edx_ace/channel/mixins.py,sha256=kmSBFcBU2OhMqlzaDZPrZpT3lh6ccte0cQeCCvo8FNc,1313
|
19
|
+
edx_ace/channel/push_notification.py,sha256=BU2yE8bGveg0gf1ahm2o_moMCifT9WMIAV4-_bVtNvI,4200
|
19
20
|
edx_ace/channel/sailthru.py,sha256=IQlM7krP8I9045FXkW01_rzJ--5Z0rRlxYIKIk-_w3I,12699
|
21
|
+
edx_ace/push_notifications/views/__init__.py,sha256=KqpUNIeKdSnIG-jDWE37pCp9sf8C3-krpf_NVTfOcds,67
|
20
22
|
edx_ace/templatetags/acetags.py,sha256=CFVLb1j7oCN0mNNOpjaUHqQBEGlYZyRUgbYr9vS-SMc,472
|
21
23
|
edx_ace/test_utils/__init__.py,sha256=HfJqvOqVNFS0dMpj4TnnXrTWHO8nutRFdA1Qja7ktfY,1087
|
22
|
-
edx_ace/tests/test_ace.py,sha256=
|
24
|
+
edx_ace/tests/test_ace.py,sha256=H5ss4ah-v5XDJ47udl6Ob6iSVdcX0qkjPI2h0kgWp1k,3278
|
23
25
|
edx_ace/tests/test_date.py,sha256=RteoiCrc269BXnIerrtz8I0K1weldZ_Qg9Oc0Nd8ixs,639
|
24
26
|
edx_ace/tests/test_delivery.py,sha256=2y65-C3ED0A2j-pA553LWvSwNHNeio8h28JKQKrxi74,4850
|
25
27
|
edx_ace/tests/test_message.py,sha256=uAf3XltW605WieNGdi0u5p6bY_oSqLsmV5pHU3J_TiQ,4430
|
26
28
|
edx_ace/tests/test_policy.py,sha256=T6Mm_q1Bi935WMBPS7sK5XbXM5izQ3y0KW0yiaQ5_GU,1597
|
27
|
-
edx_ace/tests/test_presentation.py,sha256=
|
29
|
+
edx_ace/tests/test_presentation.py,sha256=elgVz_sR04h3KKs73G0qQBzt0CazOPWKrDF2rVC_R4U,541
|
28
30
|
edx_ace/tests/channel/test_braze.py,sha256=J7p9_xF5NTzZDC-9P8aFsV3cLc2RyDMBNVlyimwORuU,7296
|
29
|
-
edx_ace/tests/channel/test_channel_helpers.py,sha256=
|
31
|
+
edx_ace/tests/channel/test_channel_helpers.py,sha256=QwzHhdpEGkdW_WV0bfLkj8nBzHy4zFoo_lsIVlN31QU,3196
|
30
32
|
edx_ace/tests/channel/test_django_email.py,sha256=FCllUVAAtGEtDsosU-auf7zOApk-12oM_xWYs7CqVn8,4114
|
31
33
|
edx_ace/tests/channel/test_file_email.py,sha256=0kWb5xhdHJMgMNrnlEM2QV4NFLZERWgRU-3Lx0MQxLM,1795
|
34
|
+
edx_ace/tests/channel/test_push_notification.py,sha256=u6BBplfPwQLMRL02VFlWlNDEw01YpcJwCrYUXZKEKNc,7179
|
32
35
|
edx_ace/tests/channel/test_sailthru.py,sha256=Hfs-v2y9N9H2u0LlA_cJkhcuU0iC02wzwTt4pHsRS7o,3197
|
33
36
|
edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/body.html,sha256=gDeXQeQFnC2EFzdJ-OwWL7yDdkiR42sUYIxyeJJUD9s,646
|
34
37
|
edx_ace/tests/test_templates/testapp/edx_ace/testmessage/email/head.html,sha256=XvxX2Go_eKUtG0Oo1qPPMOtdS2IoGi7PSY98Di_Ev3s,19
|
@@ -36,9 +39,9 @@ edx_ace/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
39
|
edx_ace/utils/date.py,sha256=Rmz3RAUCdd30hu1qcKH7FmAypaw1aSoZg-yfZig1c8A,1483
|
37
40
|
edx_ace/utils/once.py,sha256=sY3szBh3gvvAjrKbKq4S2mCejompjh5YcYD7XOhzjGU,2024
|
38
41
|
edx_ace/utils/plugins.py,sha256=U-l-eU2uWUiiwYV-H-2DfmwjoksqskAsYwS7QnThy2Q,2090
|
39
|
-
edx_ace-1.
|
40
|
-
edx_ace-1.
|
41
|
-
edx_ace-1.
|
42
|
-
edx_ace-1.
|
43
|
-
edx_ace-1.
|
44
|
-
edx_ace-1.
|
42
|
+
edx_ace-1.10.1.dist-info/LICENSE.txt,sha256=VrSJ4gO4NCpskzfNHbaTB4VcN9Q213YdcHbpOZSwcOA,35138
|
43
|
+
edx_ace-1.10.1.dist-info/METADATA,sha256=U9p9FHLUjRfe1d8AjDfeIx0l5kshf10SToG_82-mAfo,10114
|
44
|
+
edx_ace-1.10.1.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
|
45
|
+
edx_ace-1.10.1.dist-info/entry_points.txt,sha256=fiR8u0PqGyp2qIiJxcSrYhIZ3gEwl0vIMnTPWegXwRI,332
|
46
|
+
edx_ace-1.10.1.dist-info/top_level.txt,sha256=5eg_80KI88VkeiCVqZUqcYcc_PfPOg8o1GA4HxsiRU8,8
|
47
|
+
edx_ace-1.10.1.dist-info/RECORD,,
|
@@ -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
|
|
File without changes
|
File without changes
|
File without changes
|