edx-ace 1.8.0__py2.py3-none-any.whl → 1.10.0__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 CHANGED
@@ -13,7 +13,7 @@ from .policy import Policy, PolicyResult
13
13
  from .recipient import Recipient
14
14
  from .recipient_resolver import RecipientResolver
15
15
 
16
- __version__ = '1.8.0'
16
+ __version__ = '1.10.0'
17
17
 
18
18
 
19
19
  __all__ = [
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(
@@ -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
- try:
181
- possible_channels = [
182
- channels_map.get_channel_by_name(channel_type, channel_name)
183
- for channel_name in channel_names
184
- ]
185
- except KeyError:
186
- return channels_map.get_default_channel(channel_type)
187
-
188
- # First see if any channel specifically demands to deliver this message
189
- for channel in possible_channels:
190
- if channel.overrides_delivery_for_message(message):
191
- return channel
192
-
193
- # Else the normal path: use the preferred channel for this message type
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
- return channels_map.get_default_channel(channel_type)
200
+ else:
201
+ return channels_map.get_default_channel(channel_type)
@@ -62,6 +62,7 @@ class DjangoEmailChannel(EmailChannelMixin, Channel):
62
62
  from_email=from_address,
63
63
  to=[message.recipient.email_address],
64
64
  reply_to=reply_to,
65
+ headers=message.headers,
65
66
  )
66
67
 
67
68
  mail.attach_alternative(rendered_template, 'text/html')
@@ -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/message.py CHANGED
@@ -73,6 +73,9 @@ class Message(MessageAttributeSerializationMixin, metaclass=ABCMeta):
73
73
  default=None
74
74
  )
75
75
  options = attr.ib()
76
+
77
+ # headers are only supported for DjangoEmailChannel
78
+ headers = attr.ib()
76
79
  language = attr.ib(default=None)
77
80
  log_level = attr.ib(default=None)
78
81
 
@@ -84,6 +87,10 @@ class Message(MessageAttributeSerializationMixin, metaclass=ABCMeta):
84
87
  def default_options_value(self):
85
88
  return {}
86
89
 
90
+ @headers.default
91
+ def default_headers_value(self):
92
+ return {}
93
+
87
94
  @uuid.default
88
95
  def generate_uuid(self):
89
96
  return uuid4()
edx_ace/monitoring.py CHANGED
@@ -2,16 +2,8 @@
2
2
  :mod:`edx_ace.monitoring` exposes functions that are useful for reporting ACE
3
3
  message delivery stats to monitoring services.
4
4
  """
5
- try:
6
- import newrelic.agent
7
- except ImportError:
8
- newrelic = None # pylint: disable=invalid-name
5
+ from edx_django_utils.monitoring import set_custom_attribute
9
6
 
10
7
 
11
8
  def report(key, value):
12
- report_to_newrelic(key, value)
13
-
14
-
15
- def report_to_newrelic(key, value):
16
- if newrelic:
17
- newrelic.agent.add_custom_parameter(key, value)
9
+ set_custom_attribute(key, value)
edx_ace/presentation.py CHANGED
@@ -9,6 +9,7 @@ from edx_ace.channel import ChannelType
9
9
 
10
10
  RENDERERS = {
11
11
  ChannelType.EMAIL: renderers.EmailRenderer(),
12
+ ChannelType.PUSH: renderers.PushNotificationRenderer(),
12
13
  }
13
14
 
14
15
 
@@ -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(ChannelType.PUSH, transactional_msg)
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=ChannelType.PUSH,
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.8.0
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
@@ -20,10 +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
24
+ Requires-Dist: edx-django-utils >=5.14.2
25
+ Requires-Dist: firebase-admin
23
26
  Requires-Dist: python-dateutil
24
27
  Requires-Dist: sailthru-client ==2.2.3
25
28
  Requires-Dist: six
26
29
  Requires-Dist: stevedore >=1.10.0
30
+ Provides-Extra: push_notifications
31
+ Requires-Dist: django-push-notifications[fcm] ; extra == 'push_notifications'
27
32
  Provides-Extra: sailthru
28
33
  Requires-Dist: sailthru-client <2.3,>2.2 ; extra == 'sailthru'
29
34
 
@@ -1,34 +1,37 @@
1
- edx_ace/__init__.py,sha256=QK9U8KJ2IfPBtPIHTPA9bXIqj4ofz2IBUrpkMN5u1jo,635
2
- edx_ace/ace.py,sha256=TkNk8GPe_l7u9aXD8woj3qIc9QdVEwphSPYZHGVuOBE,1788
1
+ edx_ace/__init__.py,sha256=2YWggMeh_X8nkzJwO_eu0QAE27IV-ApQC3iu_UxNg3s,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
- edx_ace/message.py,sha256=dSuAd3W3FUWXN67aJTwd970tBsk1bU-af1Vn9z2VyP4,8182
7
- edx_ace/monitoring.py,sha256=ZrPYe1eetsV2bTEqUCthEWvWV-u5Cto2oEykawilyLc,408
6
+ edx_ace/message.py,sha256=lhmPor9vnaLvC4NPyRgB-obpGjGv9Lni0obcOUTgyCg,8340
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=Vjb3gz0M1dU9aToYvX7mAWPenmpKDsAdqqv0n2TWE-A,778
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=oBHBqtqzp2UIrA0Xem_k2jHLEVDRhZMAwiqOTPll3qk,2764
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=gqF84UXnB-ZdtGxnMAI0h-kqf0SUP3P1qmspE-HhldQ,6713
14
+ edx_ace/channel/__init__.py,sha256=Hjlbr99dtpFAM7NOoaRGk4lhKodxzPpzMxaHt_uCsSk,6831
15
15
  edx_ace/channel/braze.py,sha256=k-R9jZpiBQTV0FQ3Z2gOipVgxNpBMPSLBL71muh9go4,10240
16
- edx_ace/channel/django_email.py,sha256=05HIz62m1Qac8H7jBETEANFeG-jD2O1MNBIXSFJqFFU,2133
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=PyqHV7XICEW9WjOMsEb4nzX2OXQ8pX7Y8jniZ7Mm0Lw,2044
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=uI3ysweR8eG4DxCkhoHPe_GqIPpedGbm76irOIV67lY,572
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=plIy20vA5KIrsesPJ_MOwgTcX1-Qb3-dY92Xdhv_1FI,2891
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.8.0.dist-info/LICENSE.txt,sha256=VrSJ4gO4NCpskzfNHbaTB4VcN9Q213YdcHbpOZSwcOA,35138
40
- edx_ace-1.8.0.dist-info/METADATA,sha256=e0-f3x2FSwuBY19W9VueRhxMblrfTfNeB6xfOUffrjc,9888
41
- edx_ace-1.8.0.dist-info/WHEEL,sha256=-G_t0oGuE7UD0DrSpVZnq1hHMBV9DD2XkS5v7XpmTnk,110
42
- edx_ace-1.8.0.dist-info/entry_points.txt,sha256=_snuuwhVrcq5eAeES7JSMYrv1e973sHbmzJpTd10ld4,254
43
- edx_ace-1.8.0.dist-info/top_level.txt,sha256=5eg_80KI88VkeiCVqZUqcYcc_PfPOg8o1GA4HxsiRU8,8
44
- edx_ace-1.8.0.dist-info/RECORD,,
42
+ edx_ace-1.10.0.dist-info/LICENSE.txt,sha256=VrSJ4gO4NCpskzfNHbaTB4VcN9Q213YdcHbpOZSwcOA,35138
43
+ edx_ace-1.10.0.dist-info/METADATA,sha256=N5nXdmCCAN1nBasUsHSBEhMJglAW-o7WgWXR4l40d7g,10114
44
+ edx_ace-1.10.0.dist-info/WHEEL,sha256=DZajD4pwLWue70CAfc7YaxT1wLUciNBvN_TTcvXpltE,110
45
+ edx_ace-1.10.0.dist-info/entry_points.txt,sha256=fiR8u0PqGyp2qIiJxcSrYhIZ3gEwl0vIMnTPWegXwRI,332
46
+ edx_ace-1.10.0.dist-info/top_level.txt,sha256=5eg_80KI88VkeiCVqZUqcYcc_PfPOg8o1GA4HxsiRU8,8
47
+ edx_ace-1.10.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: bdist_wheel (0.43.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any
@@ -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