Compare commits

...

No commits in common. "master" and "debian" have entirely different histories.

58 changed files with 3155 additions and 1182 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# EditorConfig: http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
quote_type = double
insert_final_newline = true
tab_width = 4
trim_trailing_whitespace = true
[*.py]
spaces_around_brackets = none
spaces_around_operators = true

View File

@ -1,12 +1,28 @@
# https://travis-ci.org/jazzband/django-push-notifications
sudo: false
language: python
addons:
apt:
sources:
- deadsnakes
packages:
- python3.5
python: "3.6"
env:
- TOXENV=py27-django111
- TOXENV=py34-django111
- TOXENV=py36-django111
- TOXENV=py36-django20
- TOXENV=py36-djangomaster
- TOXENV=flake8
cache:
directories:
- $HOME/.cache/pip
- $TRAVIS_BUILD_DIR/.tox
install:
- pip install tox
script:
- tox
sudo: false
notifications:
email:
on_failure: always
on_success: change

16
ACKNOWLEDGEMENTS.md Normal file
View File

@ -0,0 +1,16 @@
This library was created by Jerome Leclanche <jerome@leclan.ch>, for use on the
Anthill application (https://www.anthill.com).
Special thanks to the following core and frequent contributors:
Adam "Cezar" Jenkins
Arthur Silva
Camille Fabreguettes
Jamaal Scarlett
Matthew Hershberger
Pablo Martín
The full contributor list is available at the following URL:
https://github.com/jazzband/django-push-notifications/graphs/contributors

44
AUTHORS
View File

@ -1,44 +0,0 @@
This library was created by Jerome Leclanche <jerome@leclan.ch>, for use on the
Anthill application (https://www.anthill.com).
Special thanks to everyone who contributed:
Adam "Cezar" Jenkins
Alan Descoins
Ales Dokhanin
Alistair Broomhead
Andrey Zevakin
Antonin Lenfant
Arthur Silva
Avichal Pandey
Brad Pitcher
Daniel Kronovet
David Pretty
Dilvane Zanardine
Florian Finke
Florian Purchess
Francois Lebel
halak
Innocenty Enikeew
Jack Feng
Jamaal Scarlett
Jay Camp
Jeremy Morgan
Jerome Leclanche
Julien Dubiel
Lital Natan
Luke Burden
Marconi Moreto
Matthew Hershberger
Maxim Kamenkov
Mohamad Nour Chawich
Nicolas Delaby
Remigiusz Dymecki
Ruslan Kovtun
Sander Heling
Sergei Evdokimov
Sujit Nair
Thomas Iovine
Valentin Hăloiu
Wyan Jow
@hoongun

View File

@ -1,10 +1,37 @@
v1.4.1 (2016-01-11)
===================
## 1.7.0 (unreleased)
* BACKWARDS-INCOMPATIBLE: Drop support for Django Rest Framework < 3.7
## 1.6.0 (2018-01-31)
* BACKWARDS-INCOMPATIBLE: Drop support for Django < 1.11
* DJANGO: Support Django 2.0
* NEW FEATURE: Add support for WebPush
## 1.5.0 (2017-04-16)
* BACKWARDS-INCOMPATIBLE: Remove `push_notifications.api.tastypie` module. Only DRF is supported now.
* BACKWARDS-INCOMPATIBLE: Drop support for Django < 1.10
* BACKWARDS-INCOMPATIBLE: Drop support for Django Rest Framework < 3.5
* DJANGO: Support Django 1.10, 1.11
* APNS: APNS is now supported using PyAPNS2 instead of an internal implementation.
* APNS: Stricter certificate validity checks
* APNS: Allow overriding the certfile from send_message()
* APNS: Add human-readable error messages
* APNS: Support thread-id in payload
* FCM: Add support for FCM (Firebase Cloud Messaging)
* FCM: Introduce `use_fcm_notification` option to enforce legacy GCM payload
* GCM: Add GCM_ERROR_TIMEOUT setting
* GCM: Fix support for sending GCM messages to topic subscribers
* WNS: Add support for WNS (Windows Notification Service)
* MISC: Make get_expired_tokens available in push_notifications.utils
## 1.4.1 (2016-01-11)
* APNS: Increased max device token size to 100 bytes (WWDC 2015, iOS 9)
* BUGFIX: Fix an index error in the admin
v1.4.0 (2015-12-13)
===================
## 1.4.0 (2015-12-13)
* BACKWARDS-INCOMPATIBLE: Drop support for Python<3.4
* DJANGO: Support Django 1.9
* GCM: Handle canonical IDs
@ -17,12 +44,7 @@ v1.4.0 (2015-12-13)
* BUGFIX: Display models correctly in the admin on both Python 2 and 3
v1.3.1 (2015-06-30)
===================
This is an errata release.
v1.3.0 (2015-06-30)
===================
## 1.3.0 (2015-06-30)
* BACKWARDS-INCOMPATIBLE: Drop support for Python<2.7
* BACKWARDS-INCOMPATIBLE: Drop support for Django<1.8
* NEW FEATURE: Added a Django Rest Framework API. Requires DRF>=3.0.
@ -35,8 +57,8 @@ v1.3.0 (2015-06-30)
* BUGFIX: Assorted Python 3 bugfixes
* BUGFIX: Fix display of device_id in admin
v1.2.1 (2015-04-11)
===================
## 1.2.1 (2015-04-11)
* APNS, GCM: Add a db_index to the device_id field
* APNS: Use the native UUIDField on Django 1.8
* APNS: Fix timeout handling on Python 3
@ -47,8 +69,8 @@ v1.2.1 (2015-04-11)
* GCM: Properly pass kwargs in GCMDeviceQuerySet.send_message()
* BUGFIX: Fix HexIntegerField for Django 1.3
v1.2.0 (2014-10-07)
===================
## 1.2.0 (2014-10-07)
* BACKWARDS-INCOMPATIBLE: Added support for Django 1.7 migrations. South users will have to upgrade to South 1.0 or Django 1.7.
* APNS: APNS MAX_NOTIFICATION_SIZE is now a setting and its default has been increased to 2048
* APNS: Always connect with TLSv1 instead of SSLv3
@ -59,8 +81,8 @@ v1.2.0 (2014-10-07)
* BUGFIX: Fixed various issues relating HexIntegerField
* BUGFIX: Fixed issues in the admin with custom user models
v1.1.0 (2014-06-29)
===================
## 1.1.0 (2014-06-29)
* BACKWARDS-INCOMPATIBLE: The arguments for device.send_message() have changed. See README.rst for details.
* Added a date_created field to GCMDevice and APNSDevice. This field keeps track of when the Device was created.
This requires a `manage.py migrate`.
@ -71,13 +93,13 @@ v1.1.0 (2014-06-29)
* Assorted migrations bugfixes
* Added a test suite
v1.0.1 (2013-01-16)
===================
## 1.0.1 (2013-01-16)
* Migrations have been reset. If you were using migrations pre-1.0 you should upgrade to 1.0 instead and only
upgrade to 1.0.1 when you are ready to reset your migrations.
v1.0 (2013-01-15)
=================
## 1.0 (2013-01-15)
* Full Python 3 support
* GCM device_id is now a custom field based on BigIntegerField and always unsigned (it should be input as hex)
* Django versions older than 1.5 now require 'six' to be installed
@ -87,15 +109,13 @@ v1.0 (2013-01-15)
* Integrate with travis-ci
* Add an AUTHORS file
v0.9 (2013-12-17)
=================
## 0.9 (2013-12-17)
* Enable installation with pip
* Add wheel support
* Add full documentation
* Various bug fixes
v0.8 (2013-03-15)
=================
## 0.8 (2013-03-15)
* Initial release

View File

@ -1,6 +1,14 @@
### Coding style
## Jazzband
Please adhere to the coding style throughout the project.
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/)
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).
## Coding style
This project follows the [HearthSim Styleguide](https://hearthsim.info/styleguide/).
In short:
1. Always use tabs. [Here](https://leclan.ch/tabs) is a short explanation why tabs are preferred.
2. Always use double quotes for strings, unless single quotes avoid unnecessary escapes.
@ -9,9 +17,10 @@ Please adhere to the coding style throughout the project.
Also see: [How to name things in programming](http://www.slideshare.net/pirhilton/how-to-name-things-the-hardest-problem-in-programming)
Flake8 tests are available with `tox -e flake8`. Run them before you commit!
### Commits and Pull Requests
## Commits and Pull Requests
Keep the commit log as healthy as the code. It is one of the first places new contributors will look at the project.
1. No more than one change per commit. There should be no changes in a commit which are unrelated to its message.
@ -19,6 +28,7 @@ Keep the commit log as healthy as the code. It is one of the first places new co
3. Follow [these conventions](http://chris.beams.io/posts/git-commit/) when writing the commit message
When filing a Pull Request, make sure it is rebased on top of most recent master.
If you need to modify it or amend it in some way, you should always appropriately [fixup](https://help.github.com/articles/about-git-rebase/) the issues in git and force-push your changes to your fork.
If you need to modify it or amend it in some way, you should always appropriately
[fixup](https://help.github.com/articles/about-git-rebase/) the issues in git and force-push your changes to your fork.
Also see: [Github Help: Using Pull Requests](https://help.github.com/articles/using-pull-requests/)

View File

@ -1,30 +1,35 @@
django-push-notifications
=========================
.. image:: https://api.travis-ci.org/jleclanche/django-push-notifications.png
:target: https://travis-ci.org/jleclanche/django-push-notifications
.. image:: https://api.travis-ci.org/jazzband/django-push-notifications.png
:target: https://travis-ci.org/jazzband/django-push-notifications
A minimal Django app that implements Device models that can send messages through APNS and GCM.
.. image:: https://jazzband.co/static/img/badge.svg
:target: https://jazzband.co/
:alt: Jazzband
The app implements two models: ``GCMDevice`` and ``APNSDevice``. Those models share the same attributes:
A minimal Django app that implements Device models that can send messages through APNS, FCM/GCM and WNS.
The app implements three models: ``GCMDevice``, ``APNSDevice`` and ``WNSDevice``. Those models share the same attributes:
- ``name`` (optional): A name for the device.
- ``active`` (default True): A boolean that determines whether the device will be sent notifications.
- ``user`` (optional): A foreign key to auth.User, if you wish to link the device to a specific user.
- ``device_id`` (optional): A UUID for the device obtained from Android/iOS APIs, if you wish to uniquely identify it.
- ``registration_id`` (required): The GCM registration id or the APNS token for the device.
- ``device_id`` (optional): A UUID for the device obtained from Android/iOS/Windows APIs, if you wish to uniquely identify it.
- ``registration_id`` (required): The FCM/GCM registration id or the APNS token for the device.
The app also implements an admin panel, through which you can test single and bulk notifications. Select one or more
GCM or APNS devices and in the action dropdown, select "Send test message" or "Send test message in bulk", accordingly.
FCM/GCM, APNS or WNS devices and in the action dropdown, select "Send test message" or "Send test message in bulk", accordingly.
Note that sending a non-bulk test message to more than one device will just iterate over the devices and send multiple
single messages.
UPDATE_ON_DUPLICATE_REG_ID: Transform create of an existing Device (based on registration id) into a update. See below Update of device with duplicate registration ID for more details.
Dependencies
------------
Django 1.8 is required. Support for older versions is available in the release 1.2.1.
Tastypie support should work on Tastypie 0.11.0 and newer.
Django REST Framework support should work on DRF version 3.0 and newer.
- Python 2.7 or 3.4+
- Django 1.11+
- For the API module, Django REST Framework 3.7+ is required.
- For WebPush (WP), pywebpush 1.3.0+ is required. py-vapid 1.3.0+ is required for generating the WebPush private key; however this
step does not need to occur on the application server.
Setup
-----
@ -45,15 +50,21 @@ Edit your settings.py file:
)
PUSH_NOTIFICATIONS_SETTINGS = {
"GCM_API_KEY": "<your api key>",
"FCM_API_KEY": "[your api key]",
"GCM_API_KEY": "[your api key]",
"APNS_CERTIFICATE": "/path/to/your/certificate.pem",
"APNS_TOPIC": "com.example.push_test",
"WNS_PACKAGE_SECURITY_ID": "[your package security id, e.g: 'ms-app://e-3-4-6234...']",
"WNS_SECRET_KEY": "[your app secret key, e.g.: 'KDiejnLKDUWodsjmewuSZkk']",
"WP_PRIVATE_KEY": "/path/to/your/private.pem",
"WP_CLAIMS": {'sub': "mailto: development@example.com"}
}
.. note::
If you are planning on running your project with ``DEBUG=True``, then make sure you have set the
If you are planning on running your project with ``APNS_USE_SANDBOX=True``, then make sure you have set the
*development* certificate as your ``APNS_CERTIFICATE``. Otherwise the app will not be able to connect to the correct host. See settings_ for details.
You can learn more about APNS certificates `here <https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ProvisioningDevelopment.html>`_.
You can learn more about APNS certificates `here <https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html>`_.
Native Django migrations are in use. ``manage.py migrate`` will install and migrate all models.
@ -63,22 +74,232 @@ Settings list
-------------
All settings are contained in a ``PUSH_NOTIFICATIONS_SETTINGS`` dict.
In order to use GCM, you are required to include ``GCM_API_KEY``.
In order to use FCM/GCM, you are required to include ``FCM_API_KEY`` or ``GCM_API_KEY``.
For APNS, you are required to include ``APNS_CERTIFICATE``.
For WNS, you need both the ``WNS_PACKAGE_SECURITY_KEY`` and the ``WNS_SECRET_KEY``.
**General settings**
- ``USER_MODEL``: Your user model of choice. Eg. ``myapp.User``. Defaults to ``settings.AUTH_USER_MODEL``.
- ``UPDATE_ON_DUPLICATE_REG_ID``: Transform create of an existing Device (based on registration id) into a update. See below `Update of device with duplicate registration ID`_ for more details.
**APNS settings**
- ``APNS_CERTIFICATE``: Absolute path to your APNS certificate file. Certificates with passphrases are not supported.
- ``APNS_CA_CERTIFICATES``: Absolute path to a CA certificates file for APNS. Optional - do not set if not needed. Defaults to None.
- ``GCM_API_KEY``: Your API key for GCM.
- ``APNS_HOST``: The hostname used for the APNS sockets.
- When ``DEBUG=True``, this defaults to ``gateway.sandbox.push.apple.com``.
- When ``DEBUG=False``, this defaults to ``gateway.push.apple.com``.
- ``APNS_PORT``: The port used along with APNS_HOST. Defaults to 2195.
- ``GCM_POST_URL``: The full url that GCM notifications will be POSTed to. Defaults to https://android.googleapis.com/gcm/send.
- ``GCM_MAX_RECIPIENTS``: The maximum amount of recipients that can be contained per bulk message. If the ``registration_ids`` list is larger than that number, multiple bulk messages will be sent. Defaults to 1000 (the maximum amount supported by GCM).
- ``APNS_TOPIC``: The topic of the remote notification, which is typically the bundle ID for your app. If you omit this header and your APNs certificate does not specify multiple topics, the APNs server uses the certificates Subject as the default topic.
- ``APNS_USE_ALTERNATIVE_PORT``: Use port 2197 for APNS, instead of default port 443.
- ``APNS_USE_SANDBOX``: Use 'api.development.push.apple.com', instead of default host 'api.push.apple.com'.
**FCM/GCM settings**
- ``FCM_API_KEY``: Your API key for Firebase Cloud Messaging.
- ``FCM_POST_URL``: The full url that FCM notifications will be POSTed to. Defaults to https://fcm.googleapis.com/fcm/send.
- ``FCM_MAX_RECIPIENTS``: The maximum amount of recipients that can be contained per bulk message. If the ``registration_ids`` list is larger than that number, multiple bulk messages will be sent. Defaults to 1000 (the maximum amount supported by FCM).
- ``FCM_ERROR_TIMEOUT``: The timeout on FCM POSTs.
- ``GCM_API_KEY``, ``GCM_POST_URL``, ``GCM_MAX_RECIPIENTS``, ``GCM_ERROR_TIMEOUT``: Same parameters for GCM
**WNS settings**
- ``WNS_PACKAGE_SECURITY_KEY``: TODO
- ``WNS_SECRET_KEY``: TODO
**WP settings**
- Install:
.. code-block:: python
pip install pywebpush
pip install py-vapid (Only for generating key)
- Getting keys:
- Create file (claim.json) like this:
.. code-block:: bash
{
"sub": "mailto: development@example.com",
"aud": "https://android.googleapis.com"
}
- Generate public and private keys:
.. code-block:: bash
vapid --sign claim.json
No private_key.pem file found.
Do you want me to create one for you? (Y/n)Y
Do you want me to create one for you? (Y/n)Y
Generating private_key.pem
Generating public_key.pem
Include the following headers in your request:
Crypto-Key: p256ecdsa=BEFuGfKKEFp-kEBMxAIw7ng8HeH_QwnH5_h55ijKD4FRvgdJU1GVlDo8K5U5ak4cMZdQTUJlkA34llWF0xHya70
Authorization: WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2FuZHJvaWQuZ29vZ2xlYXBpcy5jb20iLCJleHAiOiIxNTA4NDkwODM2Iiwic3ViIjoibWFpbHRvOiBkZXZlbG9wbWVudEBleGFtcGxlLmNvbSJ9.r5CYMs86X3JZ4AEs76pXY5PxsnEhIFJ-0ckbibmFHZuyzfIpf1ZGIJbSI7knA4ufu7Hm8RFfEg5wWN1Yf-dR2A
- Generate client public key (applicationServerKey)
.. code-block:: bash
vapid --applicationServerKey
Application Server Key = BEFuGfKKEFp-kEBMxAIw7ng8HeH_QwnH5_h55ijKD4FRvgdJU1GVlDo8K5U5ak4cMZdQTUJlkA34llWF0xHya70
- Configure settings:
- ``WP_PRIVATE_KEY``: Absolute path to your private certificate file: os.path.join(BASE_DIR, "private_key.pem")
- ``WP_CLAIMS``: Dictionary with the same sub info like claims file: {'sub': "mailto: development@example.com"}
- ``WP_ERROR_TIMEOUT``: The timeout on WebPush POSTs. (Optional)
- ``WP_POST_URL``: A dictionary (key per browser supported) with the full url that webpush notifications will be POSTed to. (Optional)
- Configure client (javascript):
.. code-block:: javascript
// Utils functions:
function urlBase64ToUint8Array (base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4)
var base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/')
var rawData = window.atob(base64)
var outputArray = new Uint8Array(rawData.length)
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray;
}
function loadVersionBrowser (userAgent) {
var ua = userAgent, tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
if (/trident/i.test(M[1])) {
tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
return {name: 'IE', version: (tem[1] || '')};
}
if (M[1] === 'Chrome') {
tem = ua.match(/\bOPR\/(\d+)/);
if (tem != null) {
return {name: 'Opera', version: tem[1]};
}
}
M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
if ((tem = ua.match(/version\/(\d+)/i)) != null) {
M.splice(1, 1, tem[1]);
}
return {
name: M[0],
version: M[1]
};
};
var applicationServerKey = "BEFuGfKKEFp-kEBMxAIw7ng8HeH_QwnH5_h55ijKD4FRvgdJU1GVlDo8K5U5ak4cMZdQTUJlkA34llWF0xHya70";
....
// In your ready listener
if ('serviceWorker' in navigator) {
// The service worker has to store in the root of the app
// http://stackoverflow.com/questions/29874068/navigator-serviceworker-is-never-ready
var browser = loadVersionBrowser();
navigator.serviceWorker.register('navigatorPush.service.js?version=1.0.0').then(function (reg) {
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
}).then(function (sub) {
var endpointParts = sub.endpoint.split('/');
var registration_id = endpointParts[endpointParts.length - 1];
var data = {
'browser': browser.name.toUpperCase(),
'p256dh': btoa(String.fromCharCode.apply(null, new Uint8Array(sub.getKey('p256dh')))),
'auth': btoa(String.fromCharCode.apply(null, new Uint8Array(sub.getKey('auth')))),
'name': 'XXXXX',
'registration_id': registration_id
};
requestPOSTToServer(data);
})
}).catch(function (err) {
console.log(':^(', err);
});
// Example navigatorPush.service.js file
var getTitle = function (title) {
if (title === "") {
title = "TITLE DEFAULT";
}
return title;
};
var getNotificationOptions = function (message, message_tag) {
var options = {
body: message,
icon: '/img/icon_120.png',
tag: message_tag,
vibrate: [200, 100, 200, 100, 200, 100, 200]
};
return options;
};
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('push', function(event) {
try {
// Push is a JSON
var response_json = event.data.json();
var title = response_json.title;
var message = response_json.message;
var message_tag = response_json.tag;
} catch (err) {
// Push is a simple text
var title = "";
var message = event.data.text();
var message_tag = "";
}
self.registration.showNotification(getTitle(title), getNotificationOptions(message, message_tag));
// Optional: Comunicating with our js application. Send a signal
self.clients.matchAll({includeUncontrolled: true, type: 'window'}).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({
"data": message_tag,
"data_title": title,
"data_body": message});
});
});
});
// Optional: Added to that the browser opens when you click on the notification push web.
self.addEventListener('notificationclick', function(event) {
// Android doesn't close the notification when you click it
// See http://crbug.com/463146
event.notification.close();
// Check if there's already a tab open with this URL.
// If yes: focus on the tab.
// If no: open a tab with the URL.
event.waitUntil(clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(windowClients) {
for (var i = 0; i < windowClients.length; i++) {
var client = windowClients[i];
if ('focus' in client) {
return client.focus();
}
}
})
);
});
Sending messages
----------------
GCM and APNS services have slightly different semantics. The app tries to offer a common interface for both when using the models.
FCM/GCM and APNS services have slightly different semantics. The app tries to offer a common interface for both when using the models.
.. code-block:: python
@ -98,12 +319,16 @@ GCM and APNS services have slightly different semantics. The app tries to offer
device = APNSDevice.objects.get(registration_id=apns_token)
device.send_message("You've got mail") # Alert message may only be sent as text.
device.send_message(None, badge=5) # No alerts but with badge.
device.send_message(None, badge=1, extra={"foo": "bar"}) # Silent message with badge and added custom data.
device.send_message(None, content_available=1, extra={"foo": "bar"}) # Silent message with custom data.
# alert with title and body.
device.send_message(message={"title" : "Game Request", "body" : "Bob wants to play poker"}, extra={"foo": "bar"})
device.send_message("Hello again", thread_id="123", extra={"foo": "bar"}) # set thread-id to allow iOS to merge notifications
.. note::
APNS does not support sending payloads that exceed 2048 bytes (increased from 256 in 2014).
The message is only one part of the payload, if
once constructed the payload exceeds the maximum size, an ``APNSDataOverflow`` exception will be raised before anything is sent.
Reference: `Apple Payload Documentation <https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW1>`_
Sending messages in bulk
------------------------
@ -117,22 +342,71 @@ Sending messages in bulk
Sending messages in bulk makes use of the bulk mechanics offered by GCM and APNS. It is almost always preferable to send
bulk notifications instead of single ones.
Administration
--------------
It's also possible to pass badge parameter as a function which accepts token parameter in order to set different badge
value per user. Assuming User model has a method get_badge returning badge count for a user:
APNS devices which are not receiving push notifications can be set to inactive by two methods. The web admin interface for
APNS devices has a "prune devices" option. Any selected devices which are not receiving notifications will be set to inactive [1]_.
There is also a management command to prune all devices failing to receive notifications:
.. code-block:: python
.. code-block:: shell
devices.send_message(
"Happy name day!",
badge=lambda token: APNSDevice.objects.get(registration_id=token).user.get_badge()
)
$ python manage.py prune_devices
Firebase vs Google Cloud Messaging
----------------------------------
This removes all devices which are not receiving notifications.
``django-push-notifications`` supports both Google Cloud Messaging and Firebase Cloud Messaging (which is now the officially supported messaging platform from Google). When registering a device, you must pass the ``cloud_message_type`` parameter to set the cloud type that matches the device needs.
This is currently defaulting to ``'GCM'``, but may change to ``'FCM'`` at some point. You are encouraged to use the `officially supported library <https://developers.google.com/cloud-messaging/faq>`_.
For more information, please refer to the APNS feedback service_.
When using FCM, ``django-push-notifications`` will automatically use the `notification and data messages format <https://firebase.google.com/docs/cloud-messaging/concept-options#notifications_and_data_messages>`_ to be conveniently handled by Firebase devices. You may want to check the payload to see if it matches your needs, and review your notification statuses in `FCM Diagnostic console <https://support.google.com/googleplay/android-developer/answer/2663268?hl=en>`_.
.. _service: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html
.. code-block:: python
# Create a FCM device
fcm_device = GCMDevice.objects.create(registration_id="token", cloud_message_type="FCM", user=the_user)
# Send a notification message
fcm_device.send_message("This is a message")
# Send a notification message with additionnal payload
fcm_device.send_message("This is a enriched message", extra={"title": "Notification title", "icon": "icon_ressource"})
# Send a notification message with additionnal payload (alternative syntax)
fcm_device.send_message("This is a enriched message", title="Notification title", badge=6)
# Send a notification message with extra data
fcm_device.send_message("This is a message with data", extra={"other": "content", "misc": "data"})
# Send a notification message with options
fcm_device.send_message("This is a message", time_to_live=3600)
# Send a data message only
fcm_device.send_message(None, extra={"other": "content", "misc": "data"})
You can disable this default behaviour by setting ``use_fcm_notifications`` to ``False``.
.. code-block:: python
fcm_device = GCMDevice.objects.create(registration_id="token", cloud_message_type="FCM", user=the_user)
# Send a data message with classic format
fcm_device.send_message("This is a message", use_fcm_notifications=False)
Sending FCM/GCM messages to topic members
-----------------------------------------
FCM/GCM topic messaging allows your app server to send a message to multiple devices that have opted in to a particular topic. Based on the publish/subscribe model, topic messaging supports unlimited subscriptions per app. Developers can choose any topic name that matches the regular expression, "/topics/[a-zA-Z0-9-_.~%]+".
Note: gcm_send_bulk_message must be used when sending messages to topic subscribers, and setting the first param to any value other than None will result in a 400 Http error.
.. code-block:: python
from push_notifications.gcm import send_message
# First param is "None" because no Registration_id is needed, the message will be sent to all devices subscribed to the topic.
send_message(None, {"body": "Hello members of my_topic!"}, to="/topics/my_topic")
Reference: `FCM Documentation <https://firebase.google.com/docs/cloud-messaging/android/topic-messaging>`_
Exceptions
----------
@ -142,24 +416,6 @@ Exceptions
- ``apns.APNSError(NotificationError)``: Something went wrong upon sending APNS notifications.
- ``apns.APNSDataOverflow(APNSError)``: The APNS payload exceeds its maximum size and cannot be sent.
Tastypie support
----------------
The app includes tastypie-compatible resources in push_notifications.api.tastypie. These can be used as-is, or as base classes
for more involved APIs.
The following resources are available:
- ``APNSDeviceResource``
- ``GCMDeviceResource``
- ``APNSDeviceAuthenticatedResource``
- ``GCMDeviceAuthenticatedResource``
The base device resources will not ask for authentication, while the authenticated ones will link the logged in user to
the device they register.
Subclassing the authenticated resources in order to add a ``SameUserAuthentication`` and a user ``ForeignKey`` is recommended.
When registered, the APIs will show up at ``<api_root>/device/apns`` and ``<api_root>/device/gcm``, respectively.
Django REST Framework (DRF) support
-----------------------------------
@ -210,10 +466,17 @@ Routes can be added one of two ways:
# ...
)
Update of device with duplicate registration ID
-----------------------------------------------
Python 3 support
----------------
The DRF viewset enforce the uniqueness of the registration ID. In same use case it
may cause issue: If an already registered mobile change its user and it will
fail to register because the registration ID already exist.
When option ``UPDATE_ON_DUPLICATE_REG_ID`` is set to True, then any creation of
device with an already existing registration ID will be transformed into an update.
The ``UPDATE_ON_DUPLICATE_REG_ID`` only works with DRF.
``django-push-notifications`` is fully compatible with Python 3.4 & 3.5
.. [1] Any devices which are not selected, but are not receiving notifications will not be deactivated on a subsequent call to "prune devices" unless another attempt to send a message to the device fails after the call to the feedback service.

2
debian/control vendored
View File

@ -9,11 +9,13 @@ Build-Depends: debhelper (>= 9),
python3-django,
python3-djangorestframework,
python3-mock,
python3-pywebpush,
python3-setuptools,
python-all,
python-django,
python-djangorestframework,
python-mock,
python-pywebpush,
python-setuptools
Standards-Version: 3.9.8
X-Python3-Version: >= 3.4

1
debian/rules vendored
View File

@ -9,4 +9,3 @@ export PYBUILD_NAME=django-push-notifications
dh $@ --with python2,python3 --buildsystem=pybuild
override_dh_auto_test:
dh_auto_test -- --system=custom --test-args="{interpreter} tests/runtests.py"

View File

@ -1,8 +1,4 @@
__author__ = "Jerome Leclanche"
__email__ = "jerome@leclan.ch"
__version__ = "1.4.1"
import pkg_resources
class NotificationError(Exception):
pass
__version__ = pkg_resources.require("django-push-notifications")[0].version

View File

@ -1,18 +1,26 @@
from django.apps import apps
from django.contrib import admin, messages
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from .gcm import GCMError
from .models import APNSDevice, GCMDevice, get_expired_tokens
from .models import GCMDevice, WebPushDevice, WNSDevice
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
from .webpush import WebPushError
User = get_user_model()
User = apps.get_model(*SETTINGS["USER_MODEL"].split("."))
class DeviceAdmin(admin.ModelAdmin):
list_display = ("__str__", "device_id", "user", "active", "date_created")
search_fields = ("name", "device_id", "user__%s" % (User.USERNAME_FIELD))
list_filter = ("active", )
actions = ("send_message", "send_bulk_message", "prune_devices", "enable", "disable")
list_filter = ("active",)
actions = ("send_message", "send_bulk_message", "enable", "disable")
raw_id_fields = ("user",)
if hasattr(User, "USERNAME_FIELD"):
search_fields = ("name", "device_id", "user__%s" % (User.USERNAME_FIELD))
else:
search_fields = ("name", "device_id")
def send_messages(self, request, queryset, bulk=False):
"""
@ -32,15 +40,43 @@ class DeviceAdmin(admin.ModelAdmin):
ret.append(r)
except GCMError as e:
errors.append(str(e))
except WebPushError as e:
errors.append(e.message)
if bulk:
break
# Because NotRegistered and InvalidRegistration do not throw GCMError
# catch them here to display error msg.
if not bulk:
for r in ret:
if "error" in r["results"][0]:
errors.append(r["results"][0]["error"])
else:
try:
errors = [r["error"] for r in ret[0][0]["results"] if "error" in r]
except TypeError:
for entry in ret[0][0]:
errors = errors + [r["error"] for r in entry["results"] if "error" in r]
if errors:
self.message_user(request, _("Some messages could not be processed: %r" % (", ".join(errors))), level=messages.ERROR)
self.message_user(
request, _("Some messages could not be processed: %r" % (", ".join(errors))),
level=messages.ERROR
)
if ret:
if not bulk:
ret = ", ".join(ret)
if bulk:
# When the queryset exceeds the max_recipients value, the
# send_message method returns a list of dicts, one per chunk
try:
success = ret[0][0]["success"]
except TypeError:
success = 0
for entry in ret[0][0]:
success = success + entry["success"]
if success == 0:
return
elif len(errors) == len(ret):
return
if errors:
msg = _("Some messages were sent: %s" % (ret))
else:
@ -49,33 +85,32 @@ class DeviceAdmin(admin.ModelAdmin):
def send_message(self, request, queryset):
self.send_messages(request, queryset)
send_message.short_description = _("Send test message")
def send_bulk_message(self, request, queryset):
self.send_messages(request, queryset, True)
send_bulk_message.short_description = _("Send test message in bulk")
def enable(self, request, queryset):
queryset.update(active=True)
enable.short_description = _("Enable selected devices")
def disable(self, request, queryset):
queryset.update(active=False)
disable.short_description = _("Disable selected devices")
def prune_devices(self, request, queryset):
# Note that when get_expired_tokens() is called, Apple's
# feedback service resets, so, calling it again won't return
# the device again (unless a message is sent to it again). So,
# if the user doesn't select all the devices for pruning, we
# could very easily leave an expired device as active. Maybe
# this is just a bad API.
expired = get_expired_tokens()
devices = queryset.filter(registration_id__in=expired)
for d in devices:
d.active = False
d.save()
class GCMDeviceAdmin(DeviceAdmin):
list_display = (
"__str__", "device_id", "user", "active", "date_created", "cloud_message_type"
)
list_filter = ("active", "cloud_message_type")
admin.site.register(APNSDevice, DeviceAdmin)
admin.site.register(GCMDevice, DeviceAdmin)
admin.site.register(GCMDevice, GCMDeviceAdmin)
admin.site.register(WNSDevice, DeviceAdmin)
admin.site.register(WebPushDevice, DeviceAdmin)

View File

@ -1,12 +0,0 @@
from django.conf import settings
if "tastypie" in settings.INSTALLED_APPS:
# Tastypie resources are importable from the api package level (backwards compatibility)
from .tastypie import APNSDeviceResource, GCMDeviceResource, APNSDeviceAuthenticatedResource, GCMDeviceAuthenticatedResource
__all__ = [
"APNSDeviceResource",
"GCMDeviceResource",
"APNSDeviceAuthenticatedResource",
"GCMDeviceAuthenticatedResource"
]

View File

@ -1,18 +1,17 @@
from __future__ import absolute_import
from rest_framework import permissions
from rest_framework.serializers import ModelSerializer, ValidationError
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from rest_framework import permissions, status
from rest_framework.fields import IntegerField
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from ..fields import UNSIGNED_64BIT_INT_MAX_VALUE, hex_re
from ..models import GCMDevice, WebPushDevice, WNSDevice
from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
from push_notifications.models import APNSDevice, GCMDevice
from push_notifications.fields import hex_re
from push_notifications.fields import UNSIGNED_64BIT_INT_MAX_VALUE
# Fields
class HexIntegerField(IntegerField):
"""
Store an integer represented as a hex string of form "0x01".
@ -22,7 +21,7 @@ class HexIntegerField(IntegerField):
# validate hex string and convert it to the unsigned
# integer representation for internal use
try:
data = int(data, 16)
data = int(data, 16) if type(data) != int else data
except ValueError:
raise ValidationError("Device ID is not a valid hex number")
return super(HexIntegerField, self).to_internal_value(data)
@ -34,47 +33,63 @@ class HexIntegerField(IntegerField):
# Serializers
class DeviceSerializerMixin(ModelSerializer):
class Meta:
fields = ("name", "registration_id", "device_id", "active", "date_created")
read_only_fields = ("date_created", )
fields = (
"id", "name", "application_id", "registration_id", "device_id",
"active", "date_created"
)
read_only_fields = ("date_created",)
# See https://github.com/tomchristie/django-rest-framework/issues/1101
extra_kwargs = {"active": {"default": True}}
class APNSDeviceSerializer(ModelSerializer):
class UniqueRegistrationSerializerMixin(Serializer):
def validate(self, attrs):
devices = None
primary_key = None
request_method = None
class Meta(DeviceSerializerMixin.Meta):
model = APNSDevice
if self.initial_data.get("registration_id", None):
if self.instance:
request_method = "update"
primary_key = self.instance.id
else:
request_method = "create"
else:
if self.context["request"].method in ["PUT", "PATCH"]:
request_method = "update"
primary_key = self.instance.id
elif self.context["request"].method == "POST":
request_method = "create"
def validate_registration_id(self, value):
# iOS device tokens are 256-bit hexadecimal (64 characters). In 2016 Apple is increasing
# iOS device tokens to 100 bytes hexadecimal (200 characters).
Device = self.Meta.model
if request_method == "update":
reg_id = attrs.get("registration_id", self.instance.registration_id)
devices = Device.objects.filter(registration_id=reg_id) \
.exclude(id=primary_key)
elif request_method == "create":
devices = Device.objects.filter(registration_id=attrs["registration_id"])
if hex_re.match(value) is None or len(value) not in (64, 200):
raise ValidationError("Registration ID (device token) is invalid")
return value
if devices:
raise ValidationError({"registration_id": "This field must be unique."})
return attrs
class GCMDeviceSerializer(ModelSerializer):
class GCMDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer):
device_id = HexIntegerField(
help_text="ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)",
style={'input_type': 'text'},
required=False
style={"input_type": "text"},
required=False,
allow_null=True
)
class Meta(DeviceSerializerMixin.Meta):
model = GCMDevice
extra_kwargs = {
# Work around an issue with validating the uniqueness of
# registration ids of up to 4k
'registration_id': {
'validators': [
UniqueValidator(queryset=GCMDevice.objects.all())
]
}
}
fields = (
"id", "name", "registration_id", "device_id", "active", "date_created",
"cloud_message_type", "application_id",
)
extra_kwargs = {"id": {"read_only": False, "required": False}}
def validate_device_id(self, value):
# device ids are 64 bit unsigned values
@ -83,6 +98,20 @@ class GCMDeviceSerializer(ModelSerializer):
return value
class WNSDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer):
class Meta(DeviceSerializerMixin.Meta):
model = WNSDevice
class WebPushDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer):
class Meta(DeviceSerializerMixin.Meta):
model = WebPushDevice
fields = (
"id", "name", "registration_id", "active", "date_created",
"p256dh", "auth", "browser", "application_id",
)
# Permissions
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
@ -94,11 +123,38 @@ class IsOwner(permissions.BasePermission):
class DeviceViewSetMixin(object):
lookup_field = "registration_id"
def create(self, request, *args, **kwargs):
serializer = None
is_update = False
if SETTINGS.get("UPDATE_ON_DUPLICATE_REG_ID") and "registration_id" in request.data:
instance = self.queryset.model.objects.filter(
registration_id=request.data["registration_id"]
).first()
if instance:
serializer = self.get_serializer(instance, data=request.data)
is_update = True
if not serializer:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if is_update:
self.perform_update(serializer)
return Response(serializer.data)
else:
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
serializer.save(user=self.request.user)
return super(DeviceViewSetMixin, self).perform_create(serializer)
def perform_update(self, serializer):
if self.request.user.is_authenticated:
serializer.save(user=self.request.user)
return super(DeviceViewSetMixin, self).perform_update(serializer)
class AuthorizedMixin(object):
permission_classes = (permissions.IsAuthenticated, IsOwner)
@ -109,15 +165,6 @@ class AuthorizedMixin(object):
# ViewSets
class APNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
queryset = APNSDevice.objects.all()
serializer_class = APNSDeviceSerializer
class APNSDeviceAuthorizedViewSet(AuthorizedMixin, APNSDeviceViewSet):
pass
class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
queryset = GCMDevice.objects.all()
serializer_class = GCMDeviceSerializer
@ -125,3 +172,21 @@ class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
class GCMDeviceAuthorizedViewSet(AuthorizedMixin, GCMDeviceViewSet):
pass
class WNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
queryset = WNSDevice.objects.all()
serializer_class = WNSDeviceSerializer
class WNSDeviceAuthorizedViewSet(AuthorizedMixin, WNSDeviceViewSet):
pass
class WebPushDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
queryset = WebPushDevice.objects.all()
serializer_class = WebPushDeviceSerializer
class WebPushDeviceAuthorizedViewSet(AuthorizedMixin, WebPushDeviceViewSet):
pass

View File

@ -1,42 +0,0 @@
from tastypie.authorization import Authorization
from tastypie.authentication import BasicAuthentication
from tastypie.resources import ModelResource
from push_notifications.models import APNSDevice, GCMDevice
class APNSDeviceResource(ModelResource):
class Meta:
authorization = Authorization()
queryset = APNSDevice.objects.all()
resource_name = "device/apns"
class GCMDeviceResource(ModelResource):
class Meta:
authorization = Authorization()
queryset = GCMDevice.objects.all()
resource_name = "device/gcm"
class APNSDeviceAuthenticatedResource(APNSDeviceResource):
# user = ForeignKey(UserResource, "user")
class Meta(APNSDeviceResource.Meta):
authentication = BasicAuthentication()
# authorization = SameUserAuthorization()
def obj_create(self, bundle, **kwargs):
# See https://github.com/toastdriven/django-tastypie/issues/854
return super(APNSDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs)
class GCMDeviceAuthenticatedResource(GCMDeviceResource):
# user = ForeignKey(UserResource, "user")
class Meta(GCMDeviceResource.Meta):
authentication = BasicAuthentication()
# authorization = SameUserAuthorization()
def obj_create(self, bundle, **kwargs):
# See https://github.com/toastdriven/django-tastypie/issues/854
return super(GCMDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs)

View File

@ -1,238 +0,0 @@
"""
Apple Push Notification Service
Documentation is available on the iOS Developer Library:
https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html
"""
import codecs
import json
import ssl
import struct
import socket
import time
from contextlib import closing
from binascii import unhexlify
from django.core.exceptions import ImproperlyConfigured
from . import NotificationError
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
class APNSError(NotificationError):
pass
class APNSServerError(APNSError):
def __init__(self, status, identifier):
super(APNSServerError, self).__init__(status, identifier)
self.status = status
self.identifier = identifier
class APNSDataOverflow(APNSError):
pass
def _apns_create_socket(address_tuple):
certfile = SETTINGS.get("APNS_CERTIFICATE")
if not certfile:
raise ImproperlyConfigured(
'You need to set PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] to send messages through APNS.'
)
try:
with open(certfile, "r") as f:
f.read()
except Exception as e:
raise ImproperlyConfigured("The APNS certificate file at %r is not readable: %s" % (certfile, e))
ca_certs = SETTINGS.get("APNS_CA_CERTIFICATES")
sock = socket.socket()
sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, certfile=certfile, ca_certs=ca_certs)
sock.connect(address_tuple)
return sock
def _apns_create_socket_to_push():
return _apns_create_socket((SETTINGS["APNS_HOST"], SETTINGS["APNS_PORT"]))
def _apns_create_socket_to_feedback():
return _apns_create_socket((SETTINGS["APNS_FEEDBACK_HOST"], SETTINGS["APNS_FEEDBACK_PORT"]))
def _apns_pack_frame(token_hex, payload, identifier, expiration, priority):
token = unhexlify(token_hex)
# |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expiration:4}|{priority:1}
frame_len = 3 * 5 + len(token) + len(payload) + 4 + 4 + 1 # 5 items, each 3 bytes prefix, then each item length
frame_fmt = "!BIBH%ssBH%ssBHIBHIBHB" % (len(token), len(payload))
frame = struct.pack(
frame_fmt,
2, frame_len,
1, len(token), token,
2, len(payload), payload,
3, 4, identifier,
4, 4, expiration,
5, 1, priority)
return frame
def _apns_check_errors(sock):
timeout = SETTINGS["APNS_ERROR_TIMEOUT"]
if timeout is None:
return # assume everything went fine!
saved_timeout = sock.gettimeout()
try:
sock.settimeout(timeout)
data = sock.recv(6)
if data:
command, status, identifier = struct.unpack("!BBI", data)
# apple protocol says command is always 8. See http://goo.gl/ENUjXg
assert command == 8, "Command must be 8!"
if status != 0:
raise APNSServerError(status, identifier)
except socket.timeout: # py3, see http://bugs.python.org/issue10272
pass
except ssl.SSLError as e: # py2
if "timed out" not in e.message:
raise
finally:
sock.settimeout(saved_timeout)
def _apns_send(token, alert, badge=None, sound=None, category=None, content_available=False,
action_loc_key=None, loc_key=None, loc_args=[], extra={}, identifier=0,
expiration=None, priority=10, socket=None):
data = {}
aps_data = {}
if action_loc_key or loc_key or loc_args:
alert = {"body": alert} if alert else {}
if action_loc_key:
alert["action-loc-key"] = action_loc_key
if loc_key:
alert["loc-key"] = loc_key
if loc_args:
alert["loc-args"] = loc_args
if alert is not None:
aps_data["alert"] = alert
if badge is not None:
aps_data["badge"] = badge
if sound is not None:
aps_data["sound"] = sound
if category is not None:
aps_data["category"] = category
if content_available:
aps_data["content-available"] = 1
data["aps"] = aps_data
data.update(extra)
# convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests)
json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
max_size = SETTINGS["APNS_MAX_NOTIFICATION_SIZE"]
if len(json_data) > max_size:
raise APNSDataOverflow("Notification body cannot exceed %i bytes" % (max_size))
# if expiration isn't specified use 1 month from now
expiration_time = expiration if expiration is not None else int(time.time()) + 2592000
frame = _apns_pack_frame(token, json_data, identifier, expiration_time, priority)
if socket:
socket.write(frame)
else:
with closing(_apns_create_socket_to_push()) as socket:
socket.write(frame)
_apns_check_errors(socket)
def _apns_read_and_unpack(socket, data_format):
length = struct.calcsize(data_format)
data = socket.recv(length)
if data:
return struct.unpack_from(data_format, data, 0)
else:
return None
def _apns_receive_feedback(socket):
expired_token_list = []
# read a timestamp (4 bytes) and device token length (2 bytes)
header_format = '!LH'
has_data = True
while has_data:
try:
# read the header tuple
header_data = _apns_read_and_unpack(socket, header_format)
if header_data is not None:
timestamp, token_length = header_data
# Unpack format for a single value of length bytes
token_format = '%ss' % token_length
device_token = _apns_read_and_unpack(socket, token_format)
if device_token is not None:
# _apns_read_and_unpack returns a tuple, but
# it's just one item, so get the first.
expired_token_list.append((timestamp, device_token[0]))
else:
has_data = False
except socket.timeout: # py3, see http://bugs.python.org/issue10272
pass
except ssl.SSLError as e: # py2
if "timed out" not in e.message:
raise
return expired_token_list
def apns_send_message(registration_id, alert, **kwargs):
"""
Sends an APNS notification to a single registration_id.
This will send the notification as form data.
If sending multiple notifications, it is more efficient to use
apns_send_bulk_message()
Note that if set alert should always be a string. If it is not set,
it won't be included in the notification. You will need to pass None
to this for silent notifications.
"""
_apns_send(registration_id, alert, **kwargs)
def apns_send_bulk_message(registration_ids, alert, **kwargs):
"""
Sends an APNS notification to one or more registration_ids.
The registration_ids argument needs to be a list.
Note that if set alert should always be a string. If it is not set,
it won't be included in the notification. You will need to pass None
to this for silent notifications.
"""
with closing(_apns_create_socket_to_push()) as socket:
for identifier, registration_id in enumerate(registration_ids):
_apns_send(registration_id, alert, identifier=identifier, socket=socket, **kwargs)
_apns_check_errors(socket)
def apns_fetch_inactive_ids():
"""
Queries the APNS server for id's that are no longer active since
the last fetch
"""
with closing(_apns_create_socket_to_feedback()) as socket:
inactive_ids = []
# Maybe we should have a flag to return the timestamp?
# It doesn't seem that useful right now, though.
for tStamp, registration_id in _apns_receive_feedback(socket):
inactive_ids.append(codecs.encode(registration_id, 'hex_codec'))
return inactive_ids

View File

@ -0,0 +1,10 @@
# flake8:noqa
try:
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
except ImportError:
# Python 2 support
from urllib2 import HTTPError, Request, urlopen
from urllib import urlencode

View File

@ -0,0 +1,21 @@
from django.utils.module_loading import import_string
from .app import AppConfig # noqa: F401
from .appmodel import AppModelConfig # noqa: F401
from .legacy import LegacyConfig # noqa: F401
from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
manager = None
def get_manager(reload=False):
global manager
if not manager or reload is True:
manager = import_string(SETTINGS["CONFIG"])()
return manager
# implementing get_manager as a function allows tests to reload settings
get_manager()

View File

@ -0,0 +1,260 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.six import string_types
from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
from .base import BaseConfig
SETTING_MISMATCH = (
"Application '{application_id}' ({platform}) does not support the setting '{setting}'."
)
# code can be "missing" or "invalid"
BAD_PLATFORM = (
'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["PLATFORM"] is {code}. '
"Must be one of: {platforms}."
)
UNKNOWN_PLATFORM = (
"Unknown Platform: {platform}. Must be one of: {platforms}."
)
MISSING_SETTING = (
'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["{setting}"] is missing.'
)
PLATFORMS = [
"FCM",
"GCM",
"WNS",
"WP",
]
# Settings that all applications must have
REQUIRED_SETTINGS = [
"PLATFORM",
]
# Settings that an application may have to enable optional features
# these settings are stubs for registry support and have no effect on the operation
# of the application at this time.
OPTIONAL_SETTINGS = [
"APPLICATION_GROUP", "APPLICATION_SECRET"
]
FCM_REQUIRED_SETTINGS = GCM_REQUIRED_SETTINGS = ["API_KEY"]
FCM_OPTIONAL_SETTINGS = GCM_OPTIONAL_SETTINGS = [
"POST_URL", "MAX_RECIPIENTS", "ERROR_TIMEOUT"
]
WNS_REQUIRED_SETTINGS = ["PACKAGE_SECURITY_ID", "SECRET_KEY"]
WNS_OPTIONAL_SETTINGS = ["WNS_ACCESS_URL"]
WP_REQUIRED_SETTINGS = ["PRIVATE_KEY", "CLAIMS"]
WP_OPTIONAL_SETTINGS = ["ERROR_TIMEOUT", "POST_URL"]
class AppConfig(BaseConfig):
"""
Supports any number of push notification enabled applications.
"""
def __init__(self, settings=None):
# supports overriding the settings to be loaded. Will load from ..settings by default.
self._settings = settings or SETTINGS
# initialize APPLICATIONS to an empty collection
self._settings.setdefault("APPLICATIONS", {})
# validate application configurations
self._validate_applications(self._settings["APPLICATIONS"])
def _validate_applications(self, apps):
"""Validate the application collection"""
for application_id, application_config in apps.items():
self._validate_config(application_id, application_config)
application_config["APPLICATION_ID"] = application_id
def _validate_config(self, application_id, application_config):
platform = application_config.get("PLATFORM", None)
# platform is not present
if platform is None:
raise ImproperlyConfigured(
BAD_PLATFORM.format(
application_id=application_id,
code="required",
platforms=", ".join(PLATFORMS)
)
)
# platform is not a valid choice from PLATFORMS
if platform not in PLATFORMS:
raise ImproperlyConfigured(
BAD_PLATFORM.format(
application_id=application_id,
code="invalid",
platforms=", ".join(PLATFORMS)
)
)
validate_fn = "_validate_{platform}_config".format(platform=platform).lower()
if hasattr(self, validate_fn):
getattr(self, validate_fn)(application_id, application_config)
else:
raise ImproperlyConfigured(
UNKNOWN_PLATFORM.format(
platform=platform,
platforms=", ".join(PLATFORMS)
)
)
def _validate_fcm_config(self, application_id, application_config):
allowed = (
REQUIRED_SETTINGS + OPTIONAL_SETTINGS + FCM_REQUIRED_SETTINGS + FCM_OPTIONAL_SETTINGS
)
self._validate_allowed_settings(application_id, application_config, allowed)
self._validate_required_settings(
application_id, application_config, FCM_REQUIRED_SETTINGS
)
application_config.setdefault("POST_URL", "https://fcm.googleapis.com/fcm/send")
application_config.setdefault("MAX_RECIPIENTS", 1000)
application_config.setdefault("ERROR_TIMEOUT", None)
def _validate_gcm_config(self, application_id, application_config):
allowed = (
REQUIRED_SETTINGS + OPTIONAL_SETTINGS + GCM_REQUIRED_SETTINGS + GCM_OPTIONAL_SETTINGS
)
self._validate_allowed_settings(application_id, application_config, allowed)
self._validate_required_settings(
application_id, application_config, GCM_REQUIRED_SETTINGS
)
application_config.setdefault("POST_URL", "https://android.googleapis.com/gcm/send")
application_config.setdefault("MAX_RECIPIENTS", 1000)
application_config.setdefault("ERROR_TIMEOUT", None)
def _validate_wns_config(self, application_id, application_config):
allowed = (
REQUIRED_SETTINGS + OPTIONAL_SETTINGS + WNS_REQUIRED_SETTINGS + WNS_OPTIONAL_SETTINGS
)
self._validate_allowed_settings(application_id, application_config, allowed)
self._validate_required_settings(
application_id, application_config, WNS_REQUIRED_SETTINGS
)
application_config.setdefault("WNS_ACCESS_URL", "https://login.live.com/accesstoken.srf")
def _validate_wp_config(self, application_id, application_config):
allowed = (
REQUIRED_SETTINGS + OPTIONAL_SETTINGS + WP_REQUIRED_SETTINGS + WP_OPTIONAL_SETTINGS
)
self._validate_allowed_settings(application_id, application_config, allowed)
self._validate_required_settings(
application_id, application_config, WP_REQUIRED_SETTINGS
)
application_config.setdefault("POST_URL", {
"CHROME": "https://fcm.googleapis.com/fcm/send",
"OPERA": "https://fcm.googleapis.com/fcm/send",
"FIREFOX": "https://updates.push.services.mozilla.com/wpush/v2",
})
def _validate_allowed_settings(self, application_id, application_config, allowed_settings):
"""Confirm only allowed settings are present."""
for setting_key in application_config.keys():
if setting_key not in allowed_settings:
raise ImproperlyConfigured(
"Platform {}, app {} does not support the setting: {}.".format(
application_config["PLATFORM"], application_id, setting_key
)
)
def _validate_required_settings(
self, application_id, application_config, required_settings
):
"""All required keys must be present"""
for setting_key in required_settings:
if setting_key not in application_config.keys():
raise ImproperlyConfigured(
MISSING_SETTING.format(
application_id=application_id, setting=setting_key
)
)
def _get_application_settings(self, application_id, platform, settings_key):
"""
Walks through PUSH_NOTIFICATIONS_SETTINGS to find the correct setting value
or raises ImproperlyConfigured.
"""
if not application_id:
conf_cls = "push_notifications.conf.AppConfig"
raise ImproperlyConfigured(
"{} requires the application_id be specified at all times.".format(conf_cls)
)
# verify that the application config exists
app_config = self._settings.get("APPLICATIONS").get(application_id, None)
if app_config is None:
raise ImproperlyConfigured(
"No application configured with application_id: {}.".format(application_id)
)
# fetch a setting for the incorrect type of platform
if app_config.get("PLATFORM") != platform:
raise ImproperlyConfigured(
SETTING_MISMATCH.format(
application_id=application_id,
platform=app_config.get("PLATFORM"),
setting=settings_key
)
)
# finally, try to fetch the setting
if settings_key not in app_config:
raise ImproperlyConfigured(
MISSING_SETTING.format(
application_id=application_id, setting=settings_key
)
)
return app_config.get(settings_key)
def get_gcm_api_key(self, application_id=None):
return self._get_application_settings(application_id, "GCM", "API_KEY")
def get_fcm_api_key(self, application_id=None):
return self._get_application_settings(application_id, "FCM", "API_KEY")
def get_post_url(self, cloud_type, application_id=None):
return self._get_application_settings(application_id, cloud_type, "POST_URL")
def get_error_timeout(self, cloud_type, application_id=None):
return self._get_application_settings(application_id, cloud_type, "ERROR_TIMEOUT")
def get_max_recipients(self, cloud_type, application_id=None):
return self._get_application_settings(application_id, cloud_type, "MAX_RECIPIENTS")
def get_wns_package_security_id(self, application_id=None):
return self._get_application_settings(application_id, "WNS", "PACKAGE_SECURITY_ID")
def get_wns_secret_key(self, application_id=None):
return self._get_application_settings(application_id, "WNS", "SECRET_KEY")
def get_wp_post_url(self, application_id, browser):
return self._get_application_settings(application_id, "WP", "POST_URL")[browser]
def get_wp_private_key(self, application_id=None):
return self._get_application_settings(application_id, "WP", "PRIVATE_KEY")
def get_wp_claims(self, application_id=None):
return self._get_application_settings(application_id, "WP", "CLAIMS")

View File

@ -0,0 +1,10 @@
from .base import BaseConfig
class AppModelConfig(BaseConfig):
"""Future home of the Application Model conf adapter
Supports multiple applications in the database.
"""
pass

View File

@ -0,0 +1,29 @@
from django.core.exceptions import ImproperlyConfigured
class BaseConfig(object):
def get_fcm_api_key(self, application_id=None):
raise NotImplementedError
def get_gcm_api_key(self, application_id=None):
raise NotImplementedError
def get_wns_package_security_id(self, application_id=None):
raise NotImplementedError
def get_wns_secret_key(self, application_id=None):
raise NotImplementedError
def get_post_url(self, cloud_type, application_id=None):
raise NotImplementedError
def get_error_timeout(self, cloud_type, application_id=None):
raise NotImplementedError
def get_max_recipients(self, cloud_type, application_id=None):
raise NotImplementedError
def get_applications(self):
"""Returns a collection containing the configured applications."""
raise NotImplementedError

View File

@ -0,0 +1,90 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.six import string_types
from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
from .base import BaseConfig
__all__ = [
"LegacyConfig"
]
class empty(object):
pass
class LegacyConfig(BaseConfig):
def _get_application_settings(self, application_id, settings_key, error_message):
"""Legacy behaviour"""
if not application_id:
value = SETTINGS.get(settings_key, empty)
if value is empty:
raise ImproperlyConfigured(error_message)
return value
else:
msg = (
"LegacySettings does not support application_id. To enable "
"multiple application support, use push_notifications.conf.AppSettings."
)
raise ImproperlyConfigured(msg)
def get_gcm_api_key(self, application_id=None):
msg = (
"Set PUSH_NOTIFICATIONS_SETTINGS[\"GCM_API_KEY\"] to send messages through GCM."
)
return self._get_application_settings(application_id, "GCM_API_KEY", msg)
def get_fcm_api_key(self, application_id=None):
msg = (
"Set PUSH_NOTIFICATIONS_SETTINGS[\"FCM_API_KEY\"] to send messages through FCM."
)
return self._get_application_settings(application_id, "FCM_API_KEY", msg)
def get_post_url(self, cloud_type, application_id=None):
key = "{}_POST_URL".format(cloud_type)
msg = (
"Set PUSH_NOTIFICATIONS_SETTINGS[\"{}\"] to send messages through {}.".format(
key, cloud_type
)
)
return self._get_application_settings(application_id, key, msg)
def get_error_timeout(self, cloud_type, application_id=None):
key = "{}_ERROR_TIMEOUT".format(cloud_type)
msg = (
"Set PUSH_NOTIFICATIONS_SETTINGS[\"{}\"] to send messages through {}.".format(
key, cloud_type
)
)
return self._get_application_settings(application_id, key, msg)
def get_max_recipients(self, cloud_type, application_id=None):
key = "{}_MAX_RECIPIENTS".format(cloud_type)
msg = (
"Set PUSH_NOTIFICATIONS_SETTINGS[\"{}\"] to send messages through {}.".format(
key, cloud_type
)
)
return self._get_application_settings(application_id, key, msg)
def get_wns_package_security_id(self, application_id=None):
msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages"
return self._get_application_settings(application_id, "WNS_PACKAGE_SECURITY_ID", msg)
def get_wns_secret_key(self, application_id=None):
msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages"
return self._get_application_settings(application_id, "WNS_SECRET_KEY", msg)
def get_wp_post_url(self, application_id, browser):
msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages"
return self._get_application_settings(application_id, "WP_POST_URL", msg)[browser]
def get_wp_private_key(self, application_id=None):
msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages"
return self._get_application_settings(application_id, "WP_PRIVATE_KEY", msg)
def get_wp_claims(self, application_id=None):
msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages"
return self._get_application_settings(application_id, "WP_CLAIMS", msg)

View File

@ -0,0 +1,3 @@
class NotificationError(Exception):
pass

View File

@ -1,21 +1,22 @@
import re
import struct
from django import forms
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.core.validators import RegexValidator
from django.db import models, connection
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import connection, models
from django.utils import six
from django.utils.translation import ugettext_lazy as _
__all__ = ["HexadecimalField", "HexIntegerField"]
UNSIGNED_64BIT_INT_MIN_VALUE = 0
UNSIGNED_64BIT_INT_MAX_VALUE = 2 ** 64 - 1
__all__ = ["HexadecimalField", "HexIntegerField"]
hex_re = re.compile(r"^(([0-9A-f])|(0x[0-9A-f]))+$")
signed_integer_engines = [
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
"django.contrib.gis.db.backends.postgis",
"django.db.backends.sqlite3"
@ -47,7 +48,9 @@ class HexadecimalField(forms.CharField):
A form field that accepts only hexadecimal numbers
"""
def __init__(self, *args, **kwargs):
self.default_validators = [RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid")]
self.default_validators = [
RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid")
]
super(HexadecimalField, self).__init__(*args, **kwargs)
def prepare_value(self, value):

View File

@ -1,25 +1,33 @@
"""
Google Cloud Messaging
Previously known as C2DM
Documentation is available on the Android Developer website:
https://developer.android.com/google/gcm/index.html
Firebase Cloud Messaging
Previously known as GCM / C2DM
Documentation is available on the Firebase Developer website:
https://firebase.google.com/docs/cloud-messaging/
"""
import json
from django.core.exceptions import ImproperlyConfigured
from .compat import Request, urlopen
from .conf import get_manager
from .exceptions import NotificationError
from .models import GCMDevice
try:
from urllib.request import Request, urlopen
from urllib.parse import urlencode
except ImportError:
# Python 2 support
from urllib2 import Request, urlopen
from urllib import urlencode
from django.core.exceptions import ImproperlyConfigured
from . import NotificationError
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
# Valid keys for FCM messages. Reference:
# https://firebase.google.com/docs/cloud-messaging/http-server-ref
FCM_TARGETS_KEYS = [
"to", "condition", "notification_key"
]
FCM_OPTIONS_KEYS = [
"collapse_key", "priority", "content_available", "delay_while_idle", "time_to_live",
"restricted_package_name", "dry_run"
]
FCM_NOTIFICATIONS_PAYLOAD_KEYS = [
"title", "body", "icon", "sound", "badge", "color", "tag", "click_action",
"body_loc_key", "body_loc_args", "title_loc_key", "title_loc_args"
]
class GCMError(NotificationError):
@ -34,161 +42,171 @@ def _chunks(l, n):
yield l[i:i + n]
def _gcm_send(data, content_type):
key = SETTINGS.get("GCM_API_KEY")
if not key:
raise ImproperlyConfigured('You need to set PUSH_NOTIFICATIONS_SETTINGS["GCM_API_KEY"] to send messages through GCM.')
def _gcm_send(data, content_type, application_id):
key = get_manager().get_gcm_api_key(application_id)
headers = {
"Content-Type": content_type,
"Authorization": "key=%s" % (key),
"Content-Length": str(len(data)),
}
request = Request(SETTINGS["GCM_POST_URL"], data, headers)
return urlopen(request).read().decode("utf-8")
request = Request(get_manager().get_post_url("GCM", application_id), data, headers)
return urlopen(
request, timeout=get_manager().get_error_timeout("GCM", application_id)
).read().decode("utf-8")
def _gcm_send_plain(registration_id, data, **kwargs):
"""
Sends a GCM notification to a single registration_id.
This will send the notification as form data.
If sending multiple notifications, it is more efficient to use
gcm_send_bulk_message() with a list of registration_ids
"""
def _fcm_send(data, content_type, application_id):
key = get_manager().get_fcm_api_key(application_id)
values = {"registration_id": registration_id}
for k, v in data.items():
values["data.%s" % (k)] = v.encode("utf-8")
for k, v in kwargs.items():
if v:
if isinstance(v, bool):
# Encode bools into ints
v = 1
values[k] = v
data = urlencode(sorted(values.items())).encode("utf-8") # sorted items for tests
result = _gcm_send(data, "application/x-www-form-urlencoded;charset=UTF-8")
# Information about handling response from Google docs (https://developers.google.com/cloud-messaging/http):
# If first line starts with id, check second line:
# If second line starts with registration_id, gets its value and replace the registration tokens in your
# server database. Otherwise, get the value of Error
if result.startswith("id"):
lines = result.split("\n")
if len(lines) > 1 and lines[1].startswith("registration_id"):
new_id = lines[1].split("=")[-1]
_gcm_handle_canonical_id(new_id, registration_id)
elif result.startswith("Error="):
if result in ("Error=NotRegistered", "Error=InvalidRegistration"):
# Deactivate the problematic device
device = GCMDevice.objects.filter(registration_id=values["registration_id"])
device.update(active=0)
return result
raise GCMError(result)
return result
headers = {
"Content-Type": content_type,
"Authorization": "key=%s" % (key),
"Content-Length": str(len(data)),
}
request = Request(get_manager().get_post_url("FCM", application_id), data, headers)
return urlopen(
request, timeout=get_manager().get_error_timeout("FCM", application_id)
).read().decode("utf-8")
def _gcm_send_json(registration_ids, data, **kwargs):
"""
Sends a GCM notification to one or more registration_ids. The registration_ids
needs to be a list.
This will send the notification as json data.
"""
values = {"registration_ids": registration_ids}
if data is not None:
values["data"] = data
for k, v in kwargs.items():
if v:
values[k] = v
data = json.dumps(values, separators=(",", ":"), sort_keys=True).encode("utf-8") # keys sorted for tests
response = json.loads(_gcm_send(data, "application/json"))
if response["failure"] or response["canonical_ids"]:
def _cm_handle_response(registration_ids, response_data, cloud_type, application_id=None):
response = response_data
if response.get("failure") or response.get("canonical_ids"):
ids_to_remove, old_new_ids = [], []
throw_error = False
for index, result in enumerate(response["results"]):
error = result.get("error")
if error:
# Information from Google docs (https://developers.google.com/cloud-messaging/http)
# If error is NotRegistered or InvalidRegistration, then we will deactivate devices because this
# registration ID is no more valid and can't be used to send messages, otherwise raise error
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#error-codes
# If error is NotRegistered or InvalidRegistration, then we will deactivate devices
# because this registration ID is no more valid and can't be used to send messages,
# otherwise raise error
if error in ("NotRegistered", "InvalidRegistration"):
ids_to_remove.append(registration_ids[index])
else:
throw_error = True
# If registration_id is set, replace the original ID with the new value (canonical ID) in your
# server database. Note that the original ID is not part of the result, so you need to obtain it
# from the list of registration_ids passed in the request (using the same index).
result["original_registration_id"] = registration_ids[index]
# If registration_id is set, replace the original ID with the new value (canonical ID)
# in your server database. Note that the original ID is not part of the result, you need
# to obtain it from the list of registration_ids in the request (using the same index).
new_id = result.get("registration_id")
if new_id:
old_new_ids.append((registration_ids[index], new_id))
if ids_to_remove:
removed = GCMDevice.objects.filter(registration_id__in=ids_to_remove)
removed = GCMDevice.objects.filter(
registration_id__in=ids_to_remove, cloud_message_type=cloud_type
)
removed.update(active=0)
for old_id, new_id in old_new_ids:
_gcm_handle_canonical_id(new_id, old_id)
_cm_handle_canonical_id(new_id, old_id, cloud_type)
if throw_error:
raise GCMError(response)
return response
def _gcm_handle_canonical_id(canonical_id, current_id):
def _cm_send_request(
registration_ids, data, cloud_type="GCM", application_id=None,
use_fcm_notifications=True, **kwargs
):
"""
Handle situation when GCM server response contains canonical ID
Sends a FCM or GCM notification to one or more registration_ids as json data.
The registration_ids needs to be a list.
"""
if GCMDevice.objects.filter(registration_id=canonical_id, active=True).exists():
GCMDevice.objects.filter(registration_id=current_id).update(active=False)
payload = {"registration_ids": registration_ids} if registration_ids else {}
# If using FCM, optionnally autodiscovers notification related keys
# https://firebase.google.com/docs/cloud-messaging/concept-options#notifications_and_data_messages
if cloud_type == "FCM" and use_fcm_notifications:
notification_payload = {}
if "message" in data:
notification_payload["body"] = data.pop("message", None)
for key in FCM_NOTIFICATIONS_PAYLOAD_KEYS:
value_from_extra = data.pop(key, None)
if value_from_extra:
notification_payload[key] = value_from_extra
value_from_kwargs = kwargs.pop(key, None)
if value_from_kwargs:
notification_payload[key] = value_from_kwargs
if notification_payload:
payload["notification"] = notification_payload
if data:
payload["data"] = data
# Attach any additional non falsy keyword args (targets, options)
# See ref : https://firebase.google.com/docs/cloud-messaging/http-server-ref#table1
payload.update({
k: v for k, v in kwargs.items() if v and (k in FCM_TARGETS_KEYS or k in FCM_OPTIONS_KEYS)
})
# Sort the keys for deterministic output (useful for tests)
json_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
# Sends requests and handles the response
if cloud_type == "GCM":
response = json.loads(_gcm_send(
json_payload, "application/json", application_id=application_id
))
elif cloud_type == "FCM":
response = json.loads(_fcm_send(
json_payload, "application/json", application_id=application_id
))
else:
GCMDevice.objects.filter(registration_id=current_id).update(registration_id=canonical_id)
raise ImproperlyConfigured("cloud_type must be FCM or GCM not %s" % str(cloud_type))
return _cm_handle_response(registration_ids, response, cloud_type, application_id)
def gcm_send_message(registration_id, data, **kwargs):
def _cm_handle_canonical_id(canonical_id, current_id, cloud_type):
"""
Sends a GCM notification to a single registration_id.
Handle situation when FCM server response contains canonical ID
"""
devices = GCMDevice.objects.filter(cloud_message_type=cloud_type)
if devices.filter(registration_id=canonical_id, active=True).exists():
devices.filter(registration_id=current_id).update(active=False)
else:
devices.filter(registration_id=current_id).update(registration_id=canonical_id)
If sending multiple notifications, it is more efficient to use
gcm_send_bulk_message() with a list of registration_ids
def send_message(registration_ids, data, cloud_type, application_id=None, **kwargs):
"""
Sends a FCM (or GCM) notification to one or more registration_ids. The registration_ids
can be a list or a single string. This will send the notification as json data.
A reference of extra keyword arguments sent to the server is available here:
https://developers.google.com/cloud-messaging/server-ref#downstream
https://firebase.google.com/docs/cloud-messaging/http-server-ref#table1
"""
if cloud_type == "FCM":
max_recipients = get_manager().get_max_recipients(cloud_type, application_id)
elif cloud_type == "GCM":
max_recipients = get_manager().get_max_recipients(cloud_type, application_id)
else:
raise ImproperlyConfigured("cloud_type must be FCM or GCM not %s" % str(cloud_type))
return _gcm_send_plain(registration_id, data, **kwargs)
# Checks for valid recipient
if registration_ids is None and "/topics/" not in kwargs.get("to", ""):
return
# Bundles the registration_ids in an list if only one is sent
if not isinstance(registration_ids, list):
registration_ids = [registration_ids] if registration_ids else None
def gcm_send_bulk_message(registration_ids, data, **kwargs):
"""
Sends a GCM notification to one or more registration_ids. The registration_ids
needs to be a list.
This will send the notification as json data.
A reference of extra keyword arguments sent to the server is available here:
https://developers.google.com/cloud-messaging/server-ref#downstream
"""
# GCM only allows up to 1000 reg ids per bulk message
# https://developer.android.com/google/gcm/gcm.html#request
max_recipients = SETTINGS.get("GCM_MAX_RECIPIENTS")
if len(registration_ids) > max_recipients:
# FCM only allows up to 1000 reg ids per bulk message
# https://firebase.google.com/docs/cloud-messaging/server#http-request
if registration_ids:
ret = []
for chunk in _chunks(registration_ids, max_recipients):
ret.append(_gcm_send_json(chunk, data, **kwargs))
return ret
ret.append(_cm_send_request(
chunk, data, cloud_type=cloud_type, application_id=application_id, **kwargs
))
return ret[0] if len(ret) == 1 else ret
else:
return _cm_send_request(None, data, cloud_type=cloud_type, **kwargs)
return _gcm_send_json(registration_ids, data, **kwargs)
send_bulk_message = send_message

View File

@ -1,16 +0,0 @@
from django.core.management.base import BaseCommand
class Command(BaseCommand):
can_import_settings = True
help = 'Deactivate APNS devices that are not receiving notifications'
def handle(self, *args, **options):
from push_notifications.models import APNSDevice, get_expired_tokens
expired = get_expired_tokens()
devices = APNSDevice.objects.filter(registration_id__in=expired)
for d in devices:
self.stdout.write('deactivating [%s]' % d.registration_id)
d.active = False
d.save()
self.stdout.write('deactivated %d devices' % len(devices))

View File

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import push_notifications.fields
from django.conf import settings
from django.db import migrations, models
import push_notifications.fields
class Migration(migrations.Migration):
@ -13,22 +14,6 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='APNSDevice',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)),
('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')),
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)),
('device_id', models.UUIDField(help_text='UDID / UIDevice.identifierForVendor()', max_length=32, null=True, verbose_name='Device ID', blank=True, db_index=True)),
('registration_id', models.CharField(unique=True, max_length=64, verbose_name='Registration ID')),
('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)),
],
options={
'verbose_name': 'APNS device',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='GCMDevice',
fields=[
@ -38,7 +23,7 @@ class Migration(migrations.Migration):
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)),
('device_id', push_notifications.fields.HexIntegerField(help_text='ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)', null=True, verbose_name='Device ID', blank=True, db_index=True)),
('registration_id', models.TextField(verbose_name='Registration ID')),
('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)),
('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'GCM device',

View File

@ -12,9 +12,4 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterField(
model_name='apnsdevice',
name='registration_id',
field=models.CharField(max_length=200, unique=True, verbose_name='Registration ID'),
),
]

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.6 on 2016-06-13 20:46
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('push_notifications', '0002_auto_20160106_0850'),
]
operations = [
migrations.CreateModel(
name='WNSDevice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Name')),
('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Creation date')),
('device_id', models.UUIDField(blank=True, db_index=True, help_text='GUID()', null=True, verbose_name='Device ID')),
('registration_id', models.TextField(verbose_name='Notification URI')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'WNS device',
},
),
]

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.6 on 2016-06-13 20:46
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('push_notifications', '0003_wnsdevice'),
]
operations = [
migrations.AddField(
model_name='gcmdevice',
name='cloud_message_type',
field=models.CharField(choices=[('FCM', 'Firebase Cloud Message'), ('GCM', 'Google Cloud Message')], default='GCM', help_text='You should choose FCM or GCM', max_length=3, verbose_name='Cloud Message Type')
),
]

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('push_notifications', '0004_fcm'),
]
operations = [
migrations.AddField(
model_name='gcmdevice',
name='application_id',
field=models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='wnsdevice',
name='application_id',
field=models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True),
preserve_default=True,
),
]

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('push_notifications', '0005_applicationid'),
]
operations = [
migrations.CreateModel(
name='WebPushDevice',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)),
('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')),
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)),
('application_id', models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True)),
('registration_id', models.TextField(verbose_name='Registration ID')),
('p256dh', models.CharField(max_length=88, verbose_name='User public encryption key')),
('auth', models.CharField(max_length=24, verbose_name='User auth secret')),
('browser', models.CharField(default='CHROME', help_text='Currently only support to Chrome, Firefox and Opera browsers', max_length=10, verbose_name='Browser', choices=[('CHROME', 'Chrome'), ('FIREFOX', 'Firefox'), ('OPERA', 'Opera')])),
('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
],
options={
'verbose_name': 'WebPush device',
},
),
]

View File

@ -1,28 +1,55 @@
from __future__ import unicode_literals
from django.conf import settings
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from .fields import HexIntegerField
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
CLOUD_MESSAGE_TYPES = (
("FCM", "Firebase Cloud Message"),
("GCM", "Google Cloud Message"),
)
BROWSER_TYPES = (
("CHROME", "Chrome"),
("FIREFOX", "Firefox"),
("OPERA", "Opera"),
)
@python_2_unicode_compatible
class Device(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), blank=True, null=True)
active = models.BooleanField(verbose_name=_("Is active"), default=True,
help_text=_("Inactive devices will not be sent notifications"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)
date_created = models.DateTimeField(verbose_name=_("Creation date"), auto_now_add=True, null=True)
active = models.BooleanField(
verbose_name=_("Is active"), default=True,
help_text=_("Inactive devices will not be sent notifications")
)
user = models.ForeignKey(
SETTINGS["USER_MODEL"], blank=True, null=True, on_delete=models.CASCADE
)
date_created = models.DateTimeField(
verbose_name=_("Creation date"), auto_now_add=True, null=True
)
application_id = models.CharField(
max_length=64, verbose_name=_("Application ID"),
help_text=_(
"Opaque application identity, should be filled in for multiple"
" key/certificate access"
),
blank=True, null=True
)
class Meta:
abstract = True
def __str__(self):
return self.name or \
str(self.device_id or "") or \
return (
self.name or str(self.device_id or "") or
"%s for %s" % (self.__class__.__name__, self.user or "unknown user")
)
class GCMDeviceManager(models.Manager):
@ -33,68 +60,150 @@ class GCMDeviceManager(models.Manager):
class GCMDeviceQuerySet(models.query.QuerySet):
def send_message(self, message, **kwargs):
if self:
from .gcm import gcm_send_bulk_message
from .gcm import send_message as gcm_send_message
data = kwargs.pop("extra", {})
if message is not None:
data["message"] = message
reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True))
return gcm_send_bulk_message(registration_ids=reg_ids, data=data, **kwargs)
app_ids = self.filter(active=True).order_by(
"application_id"
).values_list("application_id", flat=True).distinct()
response = []
for cloud_type in ("FCM", "GCM"):
for app_id in app_ids:
reg_ids = list(
self.filter(
active=True, cloud_message_type=cloud_type, application_id=app_id).values_list(
"registration_id", flat=True
)
)
if reg_ids:
r = gcm_send_message(reg_ids, data, cloud_type, application_id=app_id, **kwargs)
response.append(r)
return response
class GCMDevice(Device):
# device_id cannot be a reliable primary key as fragmentation between different devices
# can make it turn out to be null and such:
# http://android-developers.blogspot.co.uk/2011/03/identifying-app-installations.html
device_id = HexIntegerField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True,
help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)"))
device_id = HexIntegerField(
verbose_name=_("Device ID"), blank=True, null=True, db_index=True,
help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)")
)
registration_id = models.TextField(verbose_name=_("Registration ID"))
cloud_message_type = models.CharField(
verbose_name=_("Cloud Message Type"), max_length=3,
choices=CLOUD_MESSAGE_TYPES, default="GCM",
help_text=_("You should choose FCM or GCM")
)
objects = GCMDeviceManager()
class Meta:
verbose_name = _("GCM device")
def send_message(self, message, **kwargs):
from .gcm import gcm_send_message
from .gcm import send_message as gcm_send_message
data = kwargs.pop("extra", {})
if message is not None:
data["message"] = message
return gcm_send_message(registration_id=self.registration_id, data=data, **kwargs)
return gcm_send_message(
self.registration_id, data, self.cloud_message_type,
application_id=self.application_id, **kwargs
)
class APNSDeviceManager(models.Manager):
class WNSDeviceManager(models.Manager):
def get_queryset(self):
return APNSDeviceQuerySet(self.model)
return WNSDeviceQuerySet(self.model)
class APNSDeviceQuerySet(models.query.QuerySet):
class WNSDeviceQuerySet(models.query.QuerySet):
def send_message(self, message, **kwargs):
if self:
from .apns import apns_send_bulk_message
reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True))
return apns_send_bulk_message(registration_ids=reg_ids, alert=message, **kwargs)
from .wns import wns_send_bulk_message
app_ids = self.filter(active=True).order_by("application_id").values_list(
"application_id", flat=True
).distinct()
res = []
for app_id in app_ids:
reg_ids = self.filter(active=True, application_id=app_id).values_list(
"registration_id", flat=True
)
r = wns_send_bulk_message(uri_list=list(reg_ids), message=message, **kwargs)
if hasattr(r, "keys"):
res += [r]
elif hasattr(r, "__getitem__"):
res += r
return res
class APNSDevice(Device):
device_id = models.UUIDField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True,
help_text="UDID / UIDevice.identifierForVendor()")
registration_id = models.CharField(verbose_name=_("Registration ID"), max_length=200, unique=True)
class WNSDevice(Device):
device_id = models.UUIDField(
verbose_name=_("Device ID"), blank=True, null=True, db_index=True,
help_text=_("GUID()")
)
registration_id = models.TextField(verbose_name=_("Notification URI"))
objects = APNSDeviceManager()
objects = WNSDeviceManager()
class Meta:
verbose_name = _("APNS device")
verbose_name = _("WNS device")
def send_message(self, message, **kwargs):
from .apns import apns_send_message
from .wns import wns_send_message
return apns_send_message(registration_id=self.registration_id, alert=message, **kwargs)
return wns_send_message(
uri=self.registration_id, message=message, application_id=self.application_id,
**kwargs
)
# This is an APNS-only function right now, but maybe GCM will implement it
# in the future. But the definition of 'expired' may not be the same. Whatevs
def get_expired_tokens():
from .apns import apns_fetch_inactive_ids
return apns_fetch_inactive_ids()
class WebPushDeviceManager(models.Manager):
def get_queryset(self):
return WebPushDeviceQuerySet(self.model)
class WebPushDeviceQuerySet(models.query.QuerySet):
def send_message(self, message, **kwargs):
devices = self.filter(active=True).order_by("application_id").distinct()
res = []
for device in devices:
res.append(device.send_message(message))
return res
class WebPushDevice(Device):
registration_id = models.TextField(verbose_name=_("Registration ID"))
p256dh = models.CharField(
verbose_name=_("User public encryption key"),
max_length=88)
auth = models.CharField(
verbose_name=_("User auth secret"),
max_length=24)
browser = models.CharField(
verbose_name=_("Browser"), max_length=10,
choices=BROWSER_TYPES, default=BROWSER_TYPES[0][0],
help_text=_("Currently only support to Chrome, Firefox and Opera browsers")
)
objects = WebPushDeviceManager()
class Meta:
verbose_name = _("WebPush device")
@property
def device_id(self):
return None
def send_message(self, message, **kwargs):
from .webpush import webpush_send_message
return webpush_send_message(
uri=self.registration_id, message=message, browser=self.browser,
auth=self.auth, p256dh=self.p256dh, application_id=self.application_id, **kwargs)

View File

@ -1,21 +1,45 @@
from django.conf import settings
PUSH_NOTIFICATIONS_SETTINGS = getattr(settings, "PUSH_NOTIFICATIONS_SETTINGS", {})
PUSH_NOTIFICATIONS_SETTINGS.setdefault(
"CONFIG", "push_notifications.conf.LegacyConfig"
)
# GCM
PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_POST_URL", "https://android.googleapis.com/gcm/send")
PUSH_NOTIFICATIONS_SETTINGS.setdefault(
"GCM_POST_URL", "https://android.googleapis.com/gcm/send"
)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_MAX_RECIPIENTS", 1000)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_ERROR_TIMEOUT", None)
# FCM
PUSH_NOTIFICATIONS_SETTINGS.setdefault(
"FCM_POST_URL", "https://fcm.googleapis.com/fcm/send"
)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("FCM_MAX_RECIPIENTS", 1000)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("FCM_ERROR_TIMEOUT", None)
# APNS
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_PORT", 2195)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_PORT", 2196)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_ERROR_TIMEOUT", None)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_MAX_NOTIFICATION_SIZE", 2048)
if settings.DEBUG:
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.sandbox.push.apple.com")
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.sandbox.push.apple.com")
else:
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.push.apple.com")
PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.push.apple.com")
# WNS
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WNS_PACKAGE_SECURITY_ID", None)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WNS_SECRET_KEY", None)
PUSH_NOTIFICATIONS_SETTINGS.setdefault(
"WNS_ACCESS_URL", "https://login.live.com/accesstoken.srf"
)
# WP (WebPush)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_POST_URL", {
"CHROME": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"],
"OPERA": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"],
"FIREFOX": "https://updates.push.services.mozilla.com/wpush/v2",
})
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_PRIVATE_KEY", None)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_CLAIMS", None)
PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_ERROR_TIMEOUT", None)
# User model
PUSH_NOTIFICATIONS_SETTINGS.setdefault("USER_MODEL", settings.AUTH_USER_MODEL)
# API endpoint settings
PUSH_NOTIFICATIONS_SETTINGS.setdefault("UPDATE_ON_DUPLICATE_REG_ID", False)

View File

@ -0,0 +1,43 @@
from pywebpush import WebPushException, webpush
from .conf import get_manager
from .exceptions import NotificationError
class WebPushError(NotificationError):
pass
def get_subscription_info(application_id, uri, browser, auth, p256dh):
url = get_manager().get_wp_post_url(application_id, browser)
return {
"endpoint": "%s/%s" % (url, uri),
"keys": {
"auth": auth,
"p256dh": p256dh,
}
}
def webpush_send_message(
uri, message, browser, auth, p256dh, application_id=None, **kwargs
):
subscription_info = get_subscription_info(application_id, uri, browser, auth, p256dh)
try:
response = webpush(
subscription_info=subscription_info,
data=message,
vapid_private_key=get_manager().get_wp_private_key(application_id),
vapid_claims=get_manager().get_wp_claims(application_id),
**kwargs
)
results = {"results": [{}]}
if not response.ok:
results["results"][0]["error"] = response.content
results["results"][0]["original_registration_id"] = response.content
else:
results["success"] = 1
return results
except WebPushException as e:
raise WebPushError(e.message)

371
push_notifications/wns.py Normal file
View File

@ -0,0 +1,371 @@
"""
Windows Notification Service
Documentation is available on the Windows Dev Center:
https://msdn.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-windows-push-notification-services--wns--overview
"""
import json
import xml.etree.ElementTree as ET
from django.core.exceptions import ImproperlyConfigured
from .compat import HTTPError, Request, urlencode, urlopen
from .conf import get_manager
from .exceptions import NotificationError
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS
class WNSError(NotificationError):
pass
class WNSAuthenticationError(WNSError):
pass
class WNSNotificationResponseError(WNSError):
pass
def _wns_authenticate(scope="notify.windows.com", application_id=None):
"""
Requests an Access token for WNS communication.
:return: dict: {'access_token': <str>, 'expires_in': <int>, 'token_type': 'bearer'}
"""
client_id = get_manager().get_wns_package_security_id(application_id)
client_secret = get_manager().get_wns_secret_key(application_id)
if not client_id:
raise ImproperlyConfigured(
'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_PACKAGE_SECURITY_ID"] to use WNS.'
)
if not client_secret:
raise ImproperlyConfigured(
'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_SECRET_KEY"] to use WNS.'
)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
params = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
}
data = urlencode(params).encode("utf-8")
request = Request(SETTINGS["WNS_ACCESS_URL"], data=data, headers=headers)
try:
response = urlopen(request)
except HTTPError as err:
if err.code == 400:
# One of your settings is probably jacked up.
# https://msdn.microsoft.com/en-us/library/windows/apps/xaml/hh868245
raise WNSAuthenticationError("Authentication failed, check your WNS settings.")
raise err
oauth_data = response.read().decode("utf-8")
try:
oauth_data = json.loads(oauth_data)
except Exception:
# Upstream WNS issue
raise WNSAuthenticationError("Received invalid JSON data from WNS.")
access_token = oauth_data.get("access_token")
if not access_token:
# Upstream WNS issue
raise WNSAuthenticationError("Access token missing from WNS response.")
return access_token
def _wns_send(uri, data, wns_type="wns/toast", application_id=None):
"""
Sends a notification data and authentication to WNS.
:param uri: str: The device's unique notification URI
:param data: dict: The notification data to be sent.
:return:
"""
access_token = _wns_authenticate(application_id=application_id)
content_type = "text/xml"
if wns_type == "wns/raw":
content_type = "application/octet-stream"
headers = {
# content_type is "text/xml" (toast/badge/tile) | "application/octet-stream" (raw)
"Content-Type": content_type,
"Authorization": "Bearer %s" % (access_token),
"X-WNS-Type": wns_type, # wns/toast | wns/badge | wns/tile | wns/raw
}
if type(data) is str:
data = data.encode("utf-8")
request = Request(uri, data, headers)
# A lot of things can happen, let them know which one.
try:
response = urlopen(request)
except HTTPError as err:
if err.code == 400:
msg = "One or more headers were specified incorrectly or conflict with another header."
elif err.code == 401:
msg = "The cloud service did not present a valid authentication ticket."
elif err.code == 403:
msg = "The cloud service is not authorized to send a notification to this URI."
elif err.code == 404:
msg = "The channel URI is not valid or is not recognized by WNS."
elif err.code == 405:
msg = "Invalid method. Only POST or DELETE is allowed."
elif err.code == 406:
msg = "The cloud service exceeded its throttle limit"
elif err.code == 410:
msg = "The channel expired."
elif err.code == 413:
msg = "The notification payload exceeds the 500 byte limit."
elif err.code == 500:
msg = "An internal failure caused notification delivery to fail."
elif err.code == 503:
msg = "The server is currently unavailable."
else:
raise err
raise WNSNotificationResponseError("HTTP %i: %s" % (err.code, msg))
return response.read().decode("utf-8")
def _wns_prepare_toast(data, **kwargs):
"""
Creates the xml tree for a `toast` notification
:param data: dict: The notification data to be converted to an xml tree.
{
"text": ["Title text", "Message Text", "Another message!"],
"image": ["src1", "src2"],
}
:return: str
"""
root = ET.Element("toast")
visual = ET.SubElement(root, "visual")
binding = ET.SubElement(visual, "binding")
binding.attrib["template"] = kwargs.pop("template", "ToastText01")
if "text" in data:
for count, item in enumerate(data["text"], start=1):
elem = ET.SubElement(binding, "text")
elem.text = item
elem.attrib["id"] = str(count)
if "image" in data:
for count, item in enumerate(data["image"], start=1):
elem = ET.SubElement(binding, "img")
elem.attrib["src"] = item
elem.attrib["id"] = str(count)
return ET.tostring(root)
def wns_send_message(
uri, message=None, xml_data=None, raw_data=None, application_id=None, **kwargs
):
"""
Sends a notification request to WNS.
There are four notification types that WNS can send: toast, tile, badge and raw.
Toast, tile, and badge can all be customized to use different
templates/icons/sounds/launch params/etc.
See docs for more information:
https://msdn.microsoft.com/en-us/library/windows/apps/br212853.aspx
There are multiple ways to input notification data:
1. The simplest and least custom notification to send is to just pass a string
to `message`. This will create a toast notification with one text element. e.g.:
"This is my notification title"
2. You can also pass a dictionary to `message`: it can only contain one or both
keys: ["text", "image"]. The value of each key must be a list with the text and
src respectively. e.g.:
{
"text": ["text1", "text2"],
"image": ["src1", "src2"],
}
3. Passing a dictionary to `xml_data` will create one of three types of
notifications depending on the dictionary data (toast, tile, badge).
See `dict_to_xml_schema` docs for more information on dictionary formatting.
4. Passing a value to `raw_data` will create a `raw` notification and send the
input data as is.
:param uri: str: The device's unique notification uri.
:param message: str|dict: The notification data to be sent.
:param xml_data: dict: A dictionary containing data to be converted to an xml tree.
:param raw_data: str: Data to be sent via a `raw` notification.
"""
# Create a simple toast notification
if message:
wns_type = "wns/toast"
if isinstance(message, str):
message = {
"text": [message, ],
}
prepared_data = _wns_prepare_toast(data=message, **kwargs)
# Create a toast/tile/badge notification from a dictionary
elif xml_data:
xml = dict_to_xml_schema(xml_data)
wns_type = "wns/%s" % xml.tag
prepared_data = ET.tostring(xml)
# Create a raw notification
elif raw_data:
wns_type = "wns/raw"
prepared_data = raw_data
else:
raise TypeError(
"At least one of the following parameters must be set:"
"`message`, `xml_data`, `raw_data`"
)
return _wns_send(
uri=uri, data=prepared_data, wns_type=wns_type, application_id=application_id
)
def wns_send_bulk_message(
uri_list, message=None, xml_data=None, raw_data=None, application_id=None, **kwargs
):
"""
WNS doesn't support bulk notification, so we loop through each uri.
:param uri_list: list: A list of uris the notification will be sent to.
:param message: str: The notification data to be sent.
:param xml_data: dict: A dictionary containing data to be converted to an xml tree.
:param raw_data: str: Data to be sent via a `raw` notification.
"""
res = []
if uri_list:
for uri in uri_list:
r = wns_send_message(
uri=uri, message=message, xml_data=xml_data,
raw_data=raw_data, application_id=application_id, **kwargs
)
res.append(r)
return res
def dict_to_xml_schema(data):
"""
Input a dictionary to be converted to xml. There should be only one key at
the top level. The value must be a dict with (required) `children` key and
(optional) `attrs` key. This will be called the `sub-element dictionary`.
The `attrs` value must be a dictionary; each value will be added to the
element's xml tag as attributes. e.g.:
{"example": {
"attrs": {
"key1": "value1",
...
},
...
}}
would result in:
<example key1="value1" key2="value2"></example>
If the value is a dict it must contain one or more keys which will be used
as the sub-element names. Each sub-element must have a value of a sub-element
dictionary(see above) or a list of sub-element dictionaries.
If the value is not a dict, it will be the value of the element.
If the value is a list, multiple elements of the same tag will be created
from each sub-element dict in the list.
:param data: dict: Used to create an XML tree. e.g.:
example_data = {
"toast": {
"attrs": {
"launch": "param",
"duration": "short",
},
"children": {
"visual": {
"children": {
"binding": {
"attrs": {"template": "ToastText01"},
"children": {
"text": [
{
"attrs": {"id": "1"},
"children": "text1",
},
{
"attrs": {"id": "2"},
"children": "text2",
},
],
},
},
},
},
},
},
}
:return: ElementTree.Element
"""
for key, value in data.items():
root = _add_element_attrs(ET.Element(key), value.get("attrs", {}))
children = value.get("children", None)
if isinstance(children, dict):
_add_sub_elements_from_dict(root, children)
return root
def _add_sub_elements_from_dict(parent, sub_dict):
"""
Add SubElements to the parent element.
:param parent: ElementTree.Element: The parent element for the newly created SubElement.
:param sub_dict: dict: Used to create a new SubElement. See `dict_to_xml_schema`
method docstring for more information. e.g.:
{"example": {
"attrs": {
"key1": "value1",
...
},
...
}}
"""
for key, value in sub_dict.items():
if isinstance(value, list):
for repeated_element in value:
sub_element = ET.SubElement(parent, key)
_add_element_attrs(sub_element, repeated_element.get("attrs", {}))
children = repeated_element.get("children", None)
if isinstance(children, dict):
_add_sub_elements_from_dict(sub_element, children)
elif isinstance(children, str):
sub_element.text = children
else:
sub_element = ET.SubElement(parent, key)
_add_element_attrs(sub_element, value.get("attrs", {}))
children = value.get("children", None)
if isinstance(children, dict):
_add_sub_elements_from_dict(sub_element, children)
elif isinstance(children, str):
sub_element.text = children
def _add_element_attrs(elem, attrs):
"""
Add attributes to the given element.
:param elem: ElementTree.Element: The element the attributes are being added to.
:param attrs: dict: A dictionary of attributes. e.g.:
{"attribute1": "value", "attribute2": "another"}
:return: ElementTree.Element
"""
for attr, value in attrs.items():
elem.attrib[attr] = value
return elem

View File

@ -1 +0,0 @@
Django

View File

@ -1,2 +1,36 @@
[wheel]
[metadata]
name = django-push-notifications
version = 1.6.0
description = Send push notifications to mobile devices through GCM, APNS or WNS and to WebPush (Chrome, Firefox and Opera) in Django
author = Jerome Leclanche
author_email = jerome@leclan.ch
url = https://github.com/jazzband/django-push-notifications
download_url = https://github.com/jazzband/django-push-notifications/tarball/master
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Web Environment
Framework :: Django
Framework :: Django :: 1.11
Framework :: Django :: 2.0
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Programming Language :: Python
Programming Language :: Python :: 2.7
Programming Language :: Python :: 3
Programming Language :: Python :: 3.4
Programming Language :: Python :: 3.5
Programming Language :: Python :: 3.6
Topic :: Internet :: WWW/HTTP
Topic :: System :: Networking
[options]
packages = find:
install_requires =
pywebpush>=1.3.0
Django>=1.11
[options.packages.find]
exclude = tests
[bdist_wheel]
universal = 1

View File

@ -1,40 +1,6 @@
#!/usr/bin/env python
import os.path
import push_notifications
from distutils.core import setup
README = open(os.path.join(os.path.dirname(__file__), "README.rst")).read()
from setuptools import setup
CLASSIFIERS = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: System :: Networking",
]
setup(
name="django-push-notifications",
packages=[
"push_notifications",
"push_notifications/api",
"push_notifications/migrations",
"push_notifications/management",
"push_notifications/management/commands",
],
author=push_notifications.__author__,
author_email=push_notifications.__email__,
classifiers=CLASSIFIERS,
description="Send push notifications to mobile devices through GCM or APNS in Django.",
download_url="https://github.com/jleclanche/django-push-notifications/tarball/master",
long_description=README,
url="https://github.com/jleclanche/django-push-notifications",
version=push_notifications.__version__,
)
setup()

View File

@ -1,12 +0,0 @@
from test_models import *
from test_gcm_push_payload import *
from test_apns_push_payload import *
from test_management_commands import *
# conditionally test rest_framework api if the DRF package is installed
try:
import rest_framework
except ImportError:
pass
else:
from test_rest_framework import *

4
tests/_mock.py Normal file
View File

@ -0,0 +1,4 @@
try:
from unittest import mock # noqa
except ImportError:
from mock import mock # noqa

View File

@ -1,17 +0,0 @@
GCM_PLAIN_RESPONSE = 'id=1:08'
GCM_JSON_RESPONSE = '{"multicast_id":108,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"1:08"}]}'
GCM_MULTIPLE_JSON_RESPONSE = ('{"multicast_id":108,"success":2,"failure":0,"canonical_ids":0,"results":'
'[{"message_id":"1:08"}, {"message_id": "1:09"}]}')
GCM_PLAIN_RESPONSE_ERROR = ['Error=NotRegistered', 'Error=InvalidRegistration']
GCM_PLAIN_RESPONSE_ERROR_B = 'Error=MismatchSenderId'
GCM_PLAIN_CANONICAL_ID_RESPONSE = "id=1:2342\nregistration_id=NEW_REGISTRATION_ID"
GCM_JSON_RESPONSE_ERROR = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":'
' [{"error": "NotRegistered"}, {"message_id": "0:1433830664381654%3449593ff9fd7ecd"}, '
'{"error": "InvalidRegistration"}]}')
GCM_JSON_RESPONSE_ERROR_B = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, '
'"results": [{"error": "MismatchSenderId"}, {"message_id": '
'"0:1433830664381654%3449593ff9fd7ecd"}, {"error": "InvalidRegistration"}]}')
GCM_DRF_INVALID_HEX_ERROR = {'device_id': [u"Device ID is not a valid hex number"]}
GCM_DRF_OUT_OF_RANGE_ERROR = {'device_id': [u"Device ID is out of range"]}
GCM_JSON_CANONICAL_ID_RESPONSE = '{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,"results":[{"registration_id":"NEW_REGISTRATION_ID","message_id":"0:1440068396670935%6868637df9fd7ecd"},{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}'
GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE = '{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,"results":[{"registration_id":"bar","message_id":"0:1440068396670935%6868637df9fd7ecd"},{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}'

49
tests/responses.py Normal file
View File

@ -0,0 +1,49 @@
# flake8: noqa
GCM_JSON = '{"cast_id":108,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"1:08"}]}'
GCM_JSON_ERROR_NOTREGISTERED = (
'{"failure": 1, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":'
' [{"error": "NotRegistered"}]}'
)
GCM_JSON_ERROR_INVALIDREGISTRATION = (
'{"failure": 1, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":'
' [{"error": "InvalidRegistration"}]}'
)
GCM_JSON_ERROR_MISMATCHSENDERID = (
'{"success":0, "failure": 1, "canonical_ids": 0, "results":'
' [{"error": "MismatchSenderId"}]}'
)
GCM_JSON_CANONICAL_ID = (
'{"failure":0,"canonical_ids":1,"success":1,"cast_id":7173139966327257000,"results":'
'[{"registration_id":"NEW_REGISTRATION_ID","message_id":"0:1440068396670935%6868637df9fd7ecd"}]}'
)
GCM_JSON_CANONICAL_ID_SAME_DEVICE = (
'{"failure":0,"canonical_ids":1,"success":1,"cast_id":7173139966327257000,"results":'
'[{"registration_id":"bar","message_id":"0:1440068396670935%6868637df9fd7ecd"}]}'
)
GCM_JSON_MULTIPLE = (
'{"multicast_id":108,"success":2,"failure":0,"canonical_ids":0,"results":'
'[{"message_id":"1:08"}, {"message_id": "1:09"}]}'
)
GCM_JSON_MULTIPLE_ERROR = (
'{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":'
' [{"error": "NotRegistered"}, {"message_id": "0:1433830664381654%3449593ff9fd7ecd"}, '
'{"error": "InvalidRegistration"}]}'
)
GCM_JSON_MULTIPLE_ERROR_B = (
'{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, '
'"results": [{"error": "MismatchSenderId"}, {"message_id": '
'"0:1433830664381654%3449593ff9fd7ecd"}, {"error": "InvalidRegistration"}]}'
)
GCM_JSON_MULTIPLE_CANONICAL_ID = (
'{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,"results":'
'[{"registration_id":"NEW_REGISTRATION_ID","message_id":"0:1440068396670935%6868637df9fd7ecd"},'
'{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}'
)
GCM_JSON_MULTIPLE_CANONICAL_ID_SAME_DEVICE = (
'{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,'
'"results":[{"registration_id":"bar","message_id":"0:1440068396670935%6868637df9fd7ecd"}'
',{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}'
)

View File

@ -1,57 +0,0 @@
#!/usr/bin/env python
import os
import sys
import unittest
def setup():
"""
set up test environment
"""
# add test/src folders to sys path
test_folder = os.path.abspath(os.path.dirname(__file__))
src_folder = os.path.abspath(os.path.join(test_folder, os.pardir))
sys.path.insert(0, test_folder)
sys.path.insert(0, src_folder)
# define settings
import django.conf
os.environ[django.conf.ENVIRONMENT_VARIABLE] = "settings"
# set up environment
from django.test.utils import setup_test_environment
setup_test_environment()
# See https://docs.djangoproject.com/en/dev/releases/1.7/#app-loading-changes
import django
if django.VERSION >= (1, 7, 0):
django.setup()
# set up database
from django.db import connection
connection.creation.create_test_db()
def tear_down():
"""
tear down test environment
"""
# destroy test database
from django.db import connection
connection.creation.destroy_test_db("not_needed")
# teardown environment
from django.test.utils import teardown_test_environment
teardown_test_environment()
# fire in the hole!
if __name__ == "__main__":
setup()
import tests
unittest.main(module=tests)
tear_down()

View File

@ -1,7 +1,10 @@
# assert warnings are enabled
import warnings
warnings.simplefilter("ignore", Warning)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
@ -21,3 +24,5 @@ SITE_ID = 1
ROOT_URLCONF = "core.urls"
SECRET_KEY = "foobar"
PUSH_NOTIFICATIONS_SETTINGS = {}

View File

@ -1,31 +0,0 @@
import mock
from django.test import TestCase
from push_notifications.apns import _apns_send, APNSDataOverflow
class APNSPushPayloadTest(TestCase):
def test_push_payload(self):
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
_apns_send("123", "Hello world",
badge=1, sound="chime", extra={"custom_data": 12345}, expiration=3, socket=socket)
p.assert_called_once_with("123",
b'{"aps":{"alert":"Hello world","badge":1,"sound":"chime"},"custom_data":12345}', 0, 3, 10)
def test_localised_push_with_empty_body(self):
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
_apns_send("123", None, loc_key="TEST_LOC_KEY", expiration=3, socket=socket)
p.assert_called_once_with("123", b'{"aps":{"alert":{"loc-key":"TEST_LOC_KEY"}}}', 0, 3, 10)
def test_using_extra(self):
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
_apns_send("123", "sample", extra={"foo": "bar"}, identifier=10, expiration=30, priority=10, socket=socket)
p.assert_called_once_with("123", b'{"aps":{"alert":"sample"},"foo":"bar"}', 10, 30, 10)
def test_oversized_payload(self):
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
self.assertRaises(APNSDataOverflow, _apns_send, "123", "_" * 2049, socket=socket)
p.assert_has_calls([])

239
tests/test_app_config.py Normal file
View File

@ -0,0 +1,239 @@
import os
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from push_notifications.conf import AppConfig
class AppConfigTestCase(TestCase):
def test_application_id_required(self):
"""Using AppConfig without an application_id raises ImproperlyConfigured."""
manager = AppConfig()
with self.assertRaises(ImproperlyConfigured):
manager._get_application_settings(None, None, None)
def test_application_not_found(self):
"""
Using AppConfig with an application_id that does not exist raises
ImproperlyConfigured.
"""
application_id = "my_fcm_app"
manager = AppConfig()
with self.assertRaises(ImproperlyConfigured):
manager._get_application_settings(application_id, "FCM", "API_KEY")
def test_platform_configured(self):
"""
Using AppConfig with an application config that does not define PLATFORM
raises ImproperlyConfigured.
"""
application_id = "my_fcm_app"
PUSH_SETTINGS = {
"APPLICATIONS": {
application_id: {}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
def test_platform_invalid(self):
"""
Using AppConfig with an invalid platform raises ImproperlyConfigured.
"""
application_id = "my_fcm_app"
PUSH_SETTINGS = {
"APPLICATIONS": {
application_id: {
"PLATFORM": "XXX"
}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
def test_platform_invalid_setting(self):
"""
Fetching application settings for the wrong platform raises ImproperlyConfigured.
"""
application_id = "my_fcm_app"
PUSH_SETTINGS = {
"APPLICATIONS": {
application_id: {
"PLATFORM": "FCM",
"API_KEY": "[my_api_key]"
}
}
}
manager = AppConfig(PUSH_SETTINGS)
def test_missing_setting(self):
"""
Missing application settings raises ImproperlyConfigured.
"""
application_id = "my_fcm_app"
PUSH_SETTINGS = {
"APPLICATIONS": {
application_id: {
"PLATFORM": "FCM"
}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
def test_get_allowed_settings_fcm(self):
"""Verify the settings allowed for FCM platform."""
#
# all settings specified, required and optional, does not raise an error.
#
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_fcm_app": {
"PLATFORM": "FCM",
"API_KEY": "...",
"POST_URL": "...",
"MAX_RECIPIENTS": "...",
"ERROR_TIMEOUT": "...",
}
}
}
AppConfig(PUSH_SETTINGS)
# missing required settings
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_fcm_app": {
"PLATFORM": "FCM",
}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
# all optional settings have default values
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_fcm_app": {
"PLATFORM": "FCM",
"API_KEY": "...",
}
}
}
manager = AppConfig(PUSH_SETTINGS)
app_config = manager._settings["APPLICATIONS"]["my_fcm_app"]
assert app_config["POST_URL"] == "https://fcm.googleapis.com/fcm/send"
assert app_config["MAX_RECIPIENTS"] == 1000
assert app_config["ERROR_TIMEOUT"] is None
def test_get_allowed_settings_gcm(self):
"""Verify the settings allowed for GCM platform."""
#
# all settings specified, required and optional, does not raise an error.
#
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_gcm_app": {
"PLATFORM": "GCM",
"API_KEY": "...",
"POST_URL": "...",
"MAX_RECIPIENTS": "...",
"ERROR_TIMEOUT": "...",
}
}
}
AppConfig(PUSH_SETTINGS)
#
# missing required settings
#
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_gcm_app": {
"PLATFORM": "GCM",
}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
# all optional settings have default values
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_gcm_app": {
"PLATFORM": "GCM",
"API_KEY": "...",
}
}
}
manager = AppConfig(PUSH_SETTINGS)
app_config = manager._settings["APPLICATIONS"]["my_gcm_app"]
assert app_config["POST_URL"] == "https://android.googleapis.com/gcm/send"
assert app_config["MAX_RECIPIENTS"] == 1000
assert app_config["ERROR_TIMEOUT"] is None
def test_get_allowed_settings_wns(self):
"""
Verify the settings allowed for WNS platform.
"""
# all settings specified, required and optional, does not raise an error.
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_wns_app": {
"PLATFORM": "WNS",
"PACKAGE_SECURITY_ID": "...",
"SECRET_KEY": "...",
"WNS_ACCESS_URL": "...",
}
}
}
AppConfig(PUSH_SETTINGS)
# missing required settings
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_wns_app": {
"PLATFORM": "WNS",
}
}
}
with self.assertRaises(ImproperlyConfigured):
AppConfig(PUSH_SETTINGS)
# all optional settings have default values
PUSH_SETTINGS = {
"APPLICATIONS": {
"my_wns_app": {
"PLATFORM": "WNS",
"PACKAGE_SECURITY_ID": "...",
"SECRET_KEY": "...",
}
}
}
manager = AppConfig(PUSH_SETTINGS)
app_config = manager._settings["APPLICATIONS"]["my_wns_app"]
assert app_config["WNS_ACCESS_URL"] == "https://login.live.com/accesstoken.srf"

View File

@ -0,0 +1,68 @@
Bag Attributes
friendlyName: Apple Development IOS Push Services: com.baseride.Magnitapp
localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C
subject=/UID=com.baseride.Magnitapp/CN=Apple Development IOS Push Services: com.baseride.Magnitapp/OU=QAMD48Y2CA/C=US
issuer=/C=US/O=Apple Inc./OU=Apple Worldwide Developer Relations/CN=Apple Worldwide Developer Relations Certification Authority
-----BEGIN CERTIFICATE-----
MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV
BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js
ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3
aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw
HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk
AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs
b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw
MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp
yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV
72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg
/hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif
u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0
EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh
MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud
IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G
CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz
IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg
dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u
cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw
cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs
ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl
ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG
A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF
ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7
Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE
6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG
hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO
0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe
7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A==
-----END CERTIFICATE-----
Bag Attributes
friendlyName: PushNotificationCloudBus
localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C
Key Attributes: <No Attributes>
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEA0W06MJky6FWGgQ2JHV3zzGwF4oHYPFOFwCEKe2nJhIZ5DKqz
MyCmQzgNYasX8GYDPLAC+JL1ji7JnpZprBLRWKpZ1EbiUvWuI4qAJNXvYfjyWoov
DJG5BBNcI5IGxCBeHHFa4NzycxobkuCkk6qMcz5btPOwzvYrMNqB02D+FSp/Xq5d
up18JdxHIv33Bs+wBDVOsjfATFMCakQGl6jvjYiuG8zr8ClB4qUeiJ+7j2aC5NzI
fiwUs835PbOa7ZpLauyBmvKPUzOr/IoTyriXTo7bP8SVURywIU9phXQQXuc0Qbiz
DWSJQMR7sdMEUWmhGLVr2wkujJOEVekkzBsgnwIDAQABAoIBACOs06jLsBxb1VnO
kHjsNEeybx4yuD8uiy47cqmrT6S/s4cw3O3steXleoIUvzM4bXy9DwSBJEtgNQBK
5x1k5zyPaFX87TjsmQl84m9j8i9iVQaPW4xslnPXSG7WxUhLqzx1IuIDQVnSLLhM
hDyTZPGMwdqFWK0oyhq8Xjk/4IiCMcYG2M14jGVvEIsjMF246v+inAIpSUwZr1FD
qzylj1FRnm0hTjXKIWrvumDiIodybFK5ruGbaKWlciokmyBaFXlt5JCzG1hrGetf
wgg6gomjqSf7WuWILjWhHr6ZeNVKm8KdyOCs0csY1DSQj+CsLjUCF8fvE+59dN2k
/u+qASECgYEA9Me6OcT6EhrbD1aqmDfg+DgFp6IOkP0We8Y2Z3Yg9fSzwRz0bZmE
T9juuelhUxDph74fHvLmE0iMrueMIbWvzF8xGef0JIpvMVQmxvslzqRLFfPRclbA
WoSWm8pzaI/X+tZetlQySoVVeS21HbzIEKnPdFBdkyC397xyV+iCQLsCgYEA2wao
llTQ9TvQYDKad6T8RsgdCnk/BwLaq6EkLI+sNOBDRlzeSYbKkfqkwIPOhORe/ibg
2OO76P8QrmqLg76hQlYK4i6k/Fwz3pRajdfQ6KxS7sOLm0x5aqrFXHVhKVnCD5C9
PldJ2mOAowAEe7HMPcNeYbX9bW6T1hcslTKkI20CgYAJxkP4dJYrzOi8dxB+3ZRd
NRd8tyrvvTt9m8+mWAA+8hOPfZGBIuU2rwnxYJFjWMSKiBwEB10KnhYIEfT1j6TC
e3ahezKzlteT17FotrSuyL661K6jazVpJ+w/sljjbwMH4DGOBFSxxxs/qISX+Gbg
y3ceROtHqcHO4baLLhytawKBgC9wosVk+5mSahDcBQ8TIj1mjLu/BULMgHaaQY6R
U/hj9s5fwRnl4yx5QIQeSHYKTPT5kMwJj6Lo1EEi/LL9cEpA/rx84+lxQx7bvT1p
2Gr9ID1tB2kMyGOtN3BOUEw3j8v1SrgdCfcOhEdJ8q6kFRvvnBrH42t3fvfpLxPl
0x2FAoGAbSkII3zPpc8mRcD3rtyOl2TlahBtXMuxfWSxsg/Zwf47crBfEbLD+5wz
7A9qnfwiDO98GJyE5/s+ynnL2PhIomCm0P13nkZuC4d9twYMtEvcD20mdQ+gsEhz
Eg8ssRvYkO8DQwAFJKJVwVtVqMcnm/fkWu8GIfgqH6/fWNev6vs=
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,66 @@
-----BEGIN CERTIFICATE-----
MIIFkTCCBHmgAwIBAgIIRc+fhlv8zowwDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV
BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js
ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3
aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw
HhcNMTUxMjE1MTIzMDE3WhcNMTYxMjE0MTIzMDE3WjCBkDEmMCQGCgmSJomT8ixk
AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs
b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw
MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAJq6041XOdS4wTOT6UeWVKr6DqZFsYSTA8TFVyqT
cZYc19KWi9gQ2NK+WwsoRxHMmtAdZxYMTecMlqD/B4r3aiNpMjZWV8x25ymjwlGa
2zLZJ6y05/j2YDAk5mNSCensQmKOB4aJ0MtCnCbONDY1GDlB1PXMqs9VsWkI+glC
T4DF0PdF6cWqeR1SRm0vm32WHBX4RkMJp4QxE2jYDS0ENWTnkqOQ0JLLk2eb/2Lq
Tk0/F7wemyOsmYpscSnuwtYM0zkl2un5eWQR0pzpBStvVQP7TWyQPmEnasIGccWK
LBftpJTCvG9eJkJhyH9UtoKMFq7r58WfggdLb/mL9ZAf+7cCAwEAAaOCAeUwggHh
MB0GA1UdDgQWBBTGL6K5Ta3vxjOLdQTBY/wDTMYpbTAJBgNVHRMEAjAAMB8GA1Ud
IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G
CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz
IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg
dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u
cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw
cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs
ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl
ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG
A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF
ADANBgkqhkiG9w0BAQUFAAOCAQEAayrzBuIGSZnIMbF+DhAlWeJNNimNSFOWX7X8
f3YKytp8F5LpvX1kvDuJwntyXgdBsziQYtMG/RweQ9DZSYnCEzWDCQFckn9F67Eb
fj1ohidh+sPgfsuV8sx5Rm9wvCdg1jSSrqxnHMDxuReX+d8DjU9e2Z1fqd7wcEbk
LJUWxBR7+0KGYOqqUa5OrGS7tYcZj0v7f3xJyqsYVFSfYk1h7WoTr11bCSdCV+0y
zzeVLQB4RQLQt+LLb1OZlj5NdM73fSicTwy3ihFxvWPTDozxfCBvIgLT3PYJBc3k
NonhJADFD84YUTCnZC/xreAbjY7ss90fKLzxJfzMB+Wskf/EKQ==
-----END CERTIFICATE-----
Bag Attributes
friendlyName: CloudTrack Push
localKeyID: C6 2F A2 B9 4D AD EF C6 33 8B 75 04 C1 63 FC 03 4C C6 29 6D
Key Attributes: <No Attributes>
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,61A87CB1762663CC
z8z9Q9eVhRazYHnvx1LOJtWp9v7UaX/YluJ8qFuH8QG1cRbn5wxYqz61ZECDNQIl
bUYRW95QQ1GO8PpNzJ+0z0tyJ63TuzZvg1GlhGtDSCpQaRfS1SGypIYsejnEwsUN
IppR63g4TJALeP80KFGetIGhvpUURiAhRV+HV44naMkUfExa12YJs6b7ZLRN4uDz
JWl41t+h/nvXKgNtIyVQIMj6rTkrLNE0YQ8fgerc8L7XSYOg0mdpp3CyLn9rkrWI
rCbxjHyudT+LzJZBr0KWJZ2FvJp3KGVGAtGhUJ3biuRqKw6syUlCSDEaRmYSiI4C
GvINoBMag7nXS6lbsEgpS8+N43tT13uxmzNDax/5ASMXuslFaD/s2GbcUXxWv2YL
+GybNO83C8TzUDavWEzUBcbWdboim+Rh2HELPFNt1fEUzyj7ekqboT5YTQ4ceLJY
dgjM9kNCKYum8Gfy5gfXPSwIGOKPo6hssHMEOVjDLM3169POfRc11KWIU4NEGZP8
4CML5mrYdP/y3KziPDyXRUvlwGNJh9mr7ucqyjfLd5fBrYZit2jE2zlD8H8UQf1F
0VMmw6Szc6pimxhLOqXu1jHfCvP9s9w5dY8s2MFKS5trevsXNI5RzDuF2cBlGk0l
3x3akNkq10jiqsN/v+BWpmhEMhf46/BqLDGIXBsDGkqVpjunJ9Hn+lc4Fkwtq73d
LSLcPit3WRifgd9NX5BJKoSEalyCHnsteWFteS+W3J1lEnH1E8Vri6V5aOefwcFI
lDn34XTB/huzi31p6301vhGftI4+qcYVm322TcSvyMR4jwZ28UCMrgFa+RH4Xn4n
W0OmbaDjDzwvXkh9RlgTyuLQNR64ZVb27kYsUGumoNmg7DbpmM6PCaTTKyMw6Tgo
CvGZ/cdpGOcgwFVM40HaIFsH12QIUeAkepHWuzkvhUlAv9mAOZtgV8q5er0kPPBs
AgTIqCWJtlkU54HGdToR59TlENKAVxO2+v98T8I28NvduMYiwP94Ihfd/pto1tAg
Ovwb3HudRNuGO/IrQokTY9B7yyldZ9YIC5suJwQ+1M06HK3D4E81GsfifQvUoZjD
4foEf+gEdHt3ayUk98oHw9k/LNKkZhRviBHvFR7NnCTY77EX4LfHR0E3h2DzWIU+
oGC8InbtN9eV8o05SiRusM3zGK9qn3nHmw/KjjO6K+FxwnoaKHYYOyL1Xdu/CJGR
0+vLKqIUTOoVK0Ox3yj2zaJWpm5rgKdTxhCoopS4LoHP+J7h24LwxcCk/rVTd6o5
YIX5MyyW7e17uB96KYPwFioCSpFQKECd929r71Mm6uitf34/FIUBoglJozuOKXDf
dnzMVRqLNA8qdX1+sN5XQeyBjFp2eokiampycIyo07buU6khEemZxvGOfQsbpHD6
AuBk4Dj/3oYqlXWmg2aGiUHsERbHnwcHGNP1QBMFnZtZTGYiK4dc5JS90drDCIXI
c1XpnJ6f5yqQZS8eUMO6x+cUxYRqCvsPyZPTP07J3zem4i/os20S5tJXH4PctzuX
YQ5JiUOVkPFG77gw1Cq/WKLppS3k7+VRcNbX9wWZb6fs/Ruo1STtPG4llFNC8DcG
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV
BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js
ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3
aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw
HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk
AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs
b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw
MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp
yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV
72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg
/hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif
u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0
EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh
MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud
IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G
CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz
IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg
dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u
cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw
cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs
ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl
ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG
A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF
ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7
Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE
6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG
hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO
0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe
7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A==
-----END CERTIFICATE-----

View File

@ -1,28 +1,77 @@
import mock
import json
from __future__ import absolute_import
from django.test import TestCase
from push_notifications.gcm import gcm_send_message, gcm_send_bulk_message
from tests.mock_responses import GCM_PLAIN_RESPONSE, GCM_JSON_RESPONSE
from push_notifications.gcm import send_bulk_message, send_message
from ._mock import mock
from .responses import GCM_JSON, GCM_JSON_MULTIPLE
class GCMPushPayloadTest(TestCase):
def test_push_payload(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p:
gcm_send_message("abc", {"message": "Hello world"})
p.assert_called_once_with(
b"data.message=Hello+world&registration_id=abc",
"application/x-www-form-urlencoded;charset=UTF-8")
def test_push_payload_params(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p:
gcm_send_message("abc", {"message": "Hello world"}, delay_while_idle=True, time_to_live=3600)
def test_fcm_push_payload(self):
with mock.patch("push_notifications.gcm._fcm_send", return_value=GCM_JSON) as p:
send_message("abc", {"message": "Hello world"}, "FCM")
p.assert_called_once_with(
b"data.message=Hello+world&delay_while_idle=1&registration_id=abc&time_to_live=3600",
"application/x-www-form-urlencoded;charset=UTF-8")
b'{"notification":{"body":"Hello world"},"registration_ids":["abc"]}',
"application/json", application_id=None)
def test_bulk_push_payload(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON_RESPONSE) as p:
gcm_send_bulk_message(["abc", "123"], {"message": "Hello world"})
def test_push_payload_with_app_id(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON) as p:
send_message("abc", {"message": "Hello world"}, "GCM")
p.assert_called_once_with(
b'{"data":{"message":"Hello world"},"registration_ids":["abc"]}',
"application/json", application_id=None)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON) as p:
send_message("abc", {"message": "Hello world"}, "GCM")
p.assert_called_once_with(
b'{"data":{"message":"Hello world"},"registration_ids":["abc"]}',
"application/json", application_id=None)
def test_fcm_push_payload_params(self):
with mock.patch("push_notifications.gcm._fcm_send", return_value=GCM_JSON) as p:
send_message(
"abc",
{"message": "Hello world", "title": "Push notification", "other": "misc"},
"FCM",
delay_while_idle=True, time_to_live=3600, foo="bar",
)
p.assert_called_once_with(
b'{"data":{"other":"misc"},"delay_while_idle":true,'
b'"notification":{"body":"Hello world","title":"Push notification"},'
b'"registration_ids":["abc"],"time_to_live":3600}',
"application/json", application_id=None)
def test_fcm_push_payload_many(self):
with mock.patch("push_notifications.gcm._fcm_send", return_value=GCM_JSON_MULTIPLE) as p:
send_bulk_message(["abc", "123"], {"message": "Hello world"}, "FCM")
p.assert_called_once_with(
b'{"notification":{"body":"Hello world"},"registration_ids":["abc","123"]}',
"application/json", application_id=None)
def test_gcm_push_payload(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON) as p:
send_message("abc", {"message": "Hello world"}, "GCM")
p.assert_called_once_with(
b'{"data":{"message":"Hello world"},"registration_ids":["abc"]}',
"application/json", application_id=None)
def test_gcm_push_payload_params(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON) as p:
send_message(
"abc", {"message": "Hello world"}, "GCM",
delay_while_idle=True, time_to_live=3600, foo="bar",
)
p.assert_called_once_with(
b'{"data":{"message":"Hello world"},"delay_while_idle":true,'
b'"registration_ids":["abc"],"time_to_live":3600}',
"application/json", application_id=None)
def test_gcm_push_payload_many(self):
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON_MULTIPLE) as p:
send_bulk_message(["abc", "123"], {"message": "Hello world"}, "GCM")
p.assert_called_once_with(
b'{"data":{"message":"Hello world"},"registration_ids":["abc","123"]}',
"application/json")
"application/json",
application_id=None)

View File

@ -0,0 +1,36 @@
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from push_notifications.conf import LegacyConfig
class LegacyConfigTestCase(TestCase):
def test_get_error_timeout(self):
config = LegacyConfig()
# confirm default value is None
assert config.get_error_timeout("GCM") is None
# confirm default value is None
assert config.get_error_timeout("FCM") is None
# confirm legacy does not support GCM with an application_id
with self.assertRaises(ImproperlyConfigured) as ic:
config.get_error_timeout("GCM", "my_app_id")
self.assertEqual(
str(ic.exception),
"LegacySettings does not support application_id. To enable multiple"
" application support, use push_notifications.conf.AppSettings."
)
# confirm legacy does not support FCM with an application_id
with self.assertRaises(ImproperlyConfigured) as ic:
config.get_error_timeout("FCM", "my_app_id")
self.assertEqual(
str(ic.exception),
"LegacySettings does not support application_id. To enable multiple"
" application support, use push_notifications.conf.AppSettings."
)

View File

@ -1,25 +0,0 @@
import mock
from django.core.management import call_command
from django.test import TestCase
from push_notifications.apns import _apns_send, APNSDataOverflow
class CommandsTestCase(TestCase):
def test_prune_devices(self):
from push_notifications.models import APNSDevice
device = APNSDevice.objects.create(
registration_id="616263", # hex encoding of b'abc'
)
with mock.patch(
'push_notifications.apns._apns_create_socket_to_feedback',
mock.MagicMock()):
with mock.patch('push_notifications.apns._apns_receive_feedback',
mock.MagicMock()) as receiver:
receiver.side_effect = lambda s: [(b'', b'abc')]
call_command('prune_devices')
device = APNSDevice.objects.get(pk=device.pk)
self.assertFalse(device.active)

View File

@ -1,232 +1,462 @@
from __future__ import absolute_import
import json
import mock
from django.test import TestCase
from django.utils import timezone
from push_notifications.models import GCMDevice, APNSDevice
from tests.mock_responses import ( GCM_PLAIN_RESPONSE,GCM_MULTIPLE_JSON_RESPONSE, GCM_PLAIN_RESPONSE_ERROR,
GCM_JSON_RESPONSE_ERROR, GCM_PLAIN_RESPONSE_ERROR_B, GCM_JSON_RESPONSE_ERROR_B,
GCM_PLAIN_CANONICAL_ID_RESPONSE, GCM_JSON_CANONICAL_ID_RESPONSE,
GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE)
from push_notifications.gcm import GCMError
from push_notifications.gcm import GCMError, send_bulk_message
from push_notifications.models import GCMDevice
from . import responses
from ._mock import mock
class ModelTestCase(TestCase):
def test_can_save_gcm_device(self):
device = GCMDevice.objects.create(
registration_id="a valid registration id"
)
assert device.id is not None
assert device.date_created is not None
assert device.date_created.date() == timezone.now().date()
class GCMModelTestCase(TestCase):
def _create_devices(self, devices):
for device in devices:
GCMDevice.objects.create(registration_id=device, cloud_message_type="GCM")
def test_can_create_save_device(self):
device = APNSDevice.objects.create(
registration_id="a valid registration id"
)
assert device.id is not None
assert device.date_created is not None
assert device.date_created.date() == timezone.now().date()
def _create_fcm_devices(self, devices):
for device in devices:
GCMDevice.objects.create(registration_id=device, cloud_message_type="FCM")
def test_gcm_send_message(self):
device = GCMDevice.objects.create(
registration_id="abc",
)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p:
device.send_message("Hello world")
p.assert_called_once_with(
b"data.message=Hello+world&registration_id=abc",
"application/x-www-form-urlencoded;charset=UTF-8")
def test_can_save_gcm_device(self):
device = GCMDevice.objects.create(
registration_id="a valid registration id", cloud_message_type="GCM"
)
assert device.id is not None
assert device.date_created is not None
assert device.date_created.date() == timezone.now().date()
def test_gcm_send_message_extra(self):
device = GCMDevice.objects.create(
registration_id="abc",
)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p:
device.send_message("Hello world", extra={"foo": "bar"})
p.assert_called_once_with(
b"data.foo=bar&data.message=Hello+world&registration_id=abc",
"application/x-www-form-urlencoded;charset=UTF-8")
def test_gcm_send_message(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM")
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"data": {"message": "Hello world"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_gcm_send_message_collapse_key(self):
device = GCMDevice.objects.create(
registration_id="abc",
)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p:
device.send_message("Hello world", collapse_key="test_key")
p.assert_called_once_with(
b"collapse_key=test_key&data.message=Hello+world&registration_id=abc",
"application/x-www-form-urlencoded;charset=UTF-8")
def test_gcm_send_message_extra(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM")
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world", extra={"foo": "bar"}, collapse_key="test_key")
p.assert_called_once_with(
json.dumps({
"collapse_key": "test_key",
"data": {"message": "Hello world", "foo": "bar"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_gcm_send_message_to_multiple_devices(self):
GCMDevice.objects.create(
registration_id="abc",
)
def test_gcm_send_message_collapse_key(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM")
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world", collapse_key="test_key")
p.assert_called_once_with(
json.dumps({
"data": {"message": "Hello world"},
"registration_ids": ["abc"],
"collapse_key": "test_key"
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
GCMDevice.objects.create(
registration_id="abc1",
)
def test_gcm_send_message_to_multiple_devices(self):
self._create_devices(["abc", "abc1"])
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"data": { "message": "Hello world" },
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json")
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"data": {"message": "Hello world"},
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_gcm_send_message_active_devices(self):
GCMDevice.objects.create(
registration_id="abc",
active=True
)
def test_gcm_send_message_active_devices(self):
GCMDevice.objects.create(registration_id="abc", active=True, cloud_message_type="GCM")
GCMDevice.objects.create(registration_id="xyz", active=False, cloud_message_type="GCM")
GCMDevice.objects.create(
registration_id="xyz",
active=False
)
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"data": {"message": "Hello world"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"data": { "message": "Hello world" },
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json")
def test_gcm_send_message_collapse_to_multiple_devices(self):
self._create_devices(["abc", "abc1"])
def test_gcm_send_message_extra_to_multiple_devices(self):
GCMDevice.objects.create(
registration_id="abc",
)
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key")
p.assert_called_once_with(
json.dumps({
"collapse_key": "test_key",
"data": {"message": "Hello world"},
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
GCMDevice.objects.create(
registration_id="abc1",
)
def test_gcm_send_message_to_single_device_with_error(self):
# these errors are device specific, device.active will be set false
devices = ["abc", "abc1"]
self._create_devices(devices)
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p:
GCMDevice.objects.all().send_message("Hello world", extra={"foo": "bar"})
p.assert_called_once_with(
json.dumps({
"data": { "foo": "bar", "message": "Hello world" },
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json")
errors = [
responses.GCM_JSON_ERROR_NOTREGISTERED,
responses.GCM_JSON_ERROR_INVALIDREGISTRATION
]
for index, error in enumerate(errors):
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=error):
device = GCMDevice.objects.get(registration_id=devices[index])
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id=devices[index]).active is False
def test_gcm_send_message_collapse_to_multiple_devices(self):
GCMDevice.objects.create(
registration_id="abc",
)
def test_gcm_send_message_to_single_device_with_error_mismatch(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM")
GCMDevice.objects.create(
registration_id="abc1",
)
with mock.patch(
"push_notifications.gcm._gcm_send",
return_value=responses.GCM_JSON_ERROR_MISMATCHSENDERID
):
# these errors are not device specific, GCMError should be thrown
with self.assertRaises(GCMError):
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id="abc").active is True
with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p:
GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key")
p.assert_called_once_with(
json.dumps({
"collapse_key": "test_key",
"data": { "message": "Hello world" },
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json")
def test_gcm_send_message_to_multiple_devices_with_error(self):
self._create_devices(["abc", "abc1", "abc2"])
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE_ERROR
):
devices = GCMDevice.objects.all()
devices.send_message("Hello World")
assert not GCMDevice.objects.get(registration_id="abc").active
assert GCMDevice.objects.get(registration_id="abc1").active
assert not GCMDevice.objects.get(registration_id="abc2").active
def test_gcm_send_message_to_single_device_with_error(self):
# these errors are device specific, device.active will be set false
device_list = ['abc', 'abc1']
self.create_devices(device_list)
for index, error in enumerate(GCM_PLAIN_RESPONSE_ERROR):
with mock.patch("push_notifications.gcm._gcm_send",
return_value=error) as p:
device = GCMDevice.objects. \
get(registration_id=device_list[index])
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id=device_list[index]).active is False
def test_gcm_send_message_to_multiple_devices_with_error_b(self):
self._create_devices(["abc", "abc1", "abc2"])
def test_gcm_send_message_to_single_device_with_error_b(self):
# these errors are not device specific, GCMError should be thrown
device_list = ['abc']
self.create_devices(device_list)
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_PLAIN_RESPONSE_ERROR_B) as p:
device = GCMDevice.objects. \
get(registration_id=device_list[0])
with self.assertRaises(GCMError):
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id=device_list[0]).active is True
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE_ERROR_B
):
devices = GCMDevice.objects.all()
with self.assertRaises(GCMError):
devices.send_message("Hello World")
assert GCMDevice.objects.get(registration_id="abc").active is True
assert GCMDevice.objects.get(registration_id="abc1").active is True
assert GCMDevice.objects.get(registration_id="abc2").active is False
def test_gcm_send_message_to_multiple_devices_with_error(self):
device_list = ['abc', 'abc1', 'abc2']
self.create_devices(device_list)
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_JSON_RESPONSE_ERROR) as p:
devices = GCMDevice.objects.all()
devices.send_message("Hello World")
assert GCMDevice.objects.get(registration_id=device_list[0]).active is False
assert GCMDevice.objects.get(registration_id=device_list[1]).active is True
assert GCMDevice.objects.get(registration_id=device_list[2]).active is False
def test_gcm_send_message_to_multiple_devices_with_canonical_id(self):
self._create_devices(["foo", "bar"])
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_MULTIPLE_CANONICAL_ID
):
GCMDevice.objects.all().send_message("Hello World")
assert not GCMDevice.objects.filter(registration_id="foo").exists()
assert GCMDevice.objects.filter(registration_id="bar").exists()
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True
def test_gcm_send_message_to_multiple_devices_with_error_b(self):
device_list = ['abc', 'abc1', 'abc2']
self.create_devices(device_list)
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_JSON_RESPONSE_ERROR_B) as p:
devices = GCMDevice.objects.all()
with self.assertRaises(GCMError):
devices.send_message("Hello World")
assert GCMDevice.objects.get(registration_id=device_list[0]).active is True
assert GCMDevice.objects.get(registration_id=device_list[1]).active is True
assert GCMDevice.objects.get(registration_id=device_list[2]).active is False
def test_gcm_send_message_to_single_user_with_canonical_id(self):
old_registration_id = "foo"
self._create_devices([old_registration_id])
def test_gcm_send_message_to_multiple_devices_with_canonical_id(self):
device_list = ['foo', 'bar']
self.create_devices(device_list)
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_JSON_CANONICAL_ID_RESPONSE):
GCMDevice.objects.all().send_message("Hello World")
assert GCMDevice.objects.filter(registration_id=device_list[0]).exists() is False
assert GCMDevice.objects.filter(registration_id=device_list[1]).exists() is True
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True
with mock.patch(
"push_notifications.gcm._gcm_send", return_value=responses.GCM_JSON_CANONICAL_ID
):
GCMDevice.objects.get(registration_id=old_registration_id).send_message("Hello World")
assert not GCMDevice.objects.filter(registration_id=old_registration_id).exists()
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists()
def test_gcm_send_message_to_single_user_with_canonical_id(self):
old_registration_id = 'foo'
self.create_devices([old_registration_id])
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_PLAIN_CANONICAL_ID_RESPONSE):
GCMDevice.objects.get(registration_id=old_registration_id).send_message("Hello World")
assert GCMDevice.objects.filter(registration_id=old_registration_id).exists() is False
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True
def test_gcm_send_message_to_same_devices_with_canonical_id(self):
first_device = GCMDevice.objects.create(
registration_id="foo", active=True, cloud_message_type="GCM"
)
second_device = GCMDevice.objects.create(
registration_id="bar", active=False, cloud_message_type="GCM"
)
def test_gcm_send_message_to_same_devices_with_canonical_id(self):
device_list = ['foo', 'bar']
self.create_devices(device_list)
first_device_pk = GCMDevice.objects.get(registration_id='foo').pk
second_device_pk = GCMDevice.objects.get(registration_id='bar').pk
with mock.patch("push_notifications.gcm._gcm_send",
return_value=GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE):
GCMDevice.objects.all().send_message("Hello World")
first_device = GCMDevice.objects.get(pk=first_device_pk)
second_device = GCMDevice.objects.get(pk=second_device_pk)
assert first_device.active is False
assert second_device.active is True
with mock.patch(
"push_notifications.gcm._gcm_send",
return_value=responses.GCM_JSON_CANONICAL_ID_SAME_DEVICE
):
GCMDevice.objects.all().send_message("Hello World")
def test_apns_send_message(self):
device = APNSDevice.objects.create(
registration_id="abc",
)
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
device.send_message("Hello world", socket=socket, expiration=1)
p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"}}', 0, 1, 10)
assert first_device.active is True
assert second_device.active is False
def test_apns_send_message_extra(self):
device = APNSDevice.objects.create(
registration_id="abc",
)
socket = mock.MagicMock()
with mock.patch("push_notifications.apns._apns_pack_frame") as p:
device.send_message("Hello world", extra={"foo": "bar"}, socket=socket, identifier=1, expiration=2, priority=5)
p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"},"foo":"bar"}', 1, 2, 5)
def test_gcm_send_message_with_no_reg_ids(self):
self._create_devices(["abc", "abc1"])
def create_devices(self, devices):
for device in devices:
GCMDevice.objects.create(
registration_id=device,
)
with mock.patch("push_notifications.gcm._cm_send_request", return_value="") as p:
GCMDevice.objects.filter(registration_id="xyz").send_message("Hello World")
p.assert_not_called()
with mock.patch("push_notifications.gcm._cm_send_request", return_value="") as p:
reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()]
send_bulk_message(reg_ids, {"message": "Hello World"}, "GCM")
p.assert_called_once_with(
[u"abc", u"abc1"], {"message": "Hello World"}, cloud_type="GCM", application_id=None
)
def test_fcm_send_message(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"notification": {"body": "Hello world"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_extra_data(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world", extra={"foo": "bar"})
p.assert_called_once_with(
json.dumps({
"data": {"foo": "bar"},
"notification": {"body": "Hello world"},
"registration_ids": ["abc"],
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json",
application_id=None
)
def test_fcm_send_message_extra_options(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world", collapse_key="test_key", foo="bar")
p.assert_called_once_with(
json.dumps({
"collapse_key": "test_key",
"notification": {"body": "Hello world"},
"registration_ids": ["abc"],
}, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json",
application_id=None
)
def test_fcm_send_message_extra_notification(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message("Hello world", extra={"icon": "test_icon"}, title="test")
p.assert_called_once_with(
json.dumps({
"notification": {"body": "Hello world", "title": "test", "icon": "test_icon"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_extra_options_and_notification_and_data(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON
) as p:
device.send_message(
"Hello world",
extra={"foo": "bar", "icon": "test_icon"},
title="test",
collapse_key="test_key"
)
p.assert_called_once_with(
json.dumps({
"notification": {"body": "Hello world", "title": "test", "icon": "test_icon"},
"data": {"foo": "bar"},
"registration_ids": ["abc"],
"collapse_key": "test_key"
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_to_multiple_devices(self):
self._create_fcm_devices(["abc", "abc1"])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"notification": {"body": "Hello world"},
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_active_devices(self):
GCMDevice.objects.create(registration_id="abc", active=True, cloud_message_type="FCM")
GCMDevice.objects.create(registration_id="xyz", active=False, cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world")
p.assert_called_once_with(
json.dumps({
"notification": {"body": "Hello world"},
"registration_ids": ["abc"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_collapse_to_multiple_devices(self):
self._create_fcm_devices(["abc", "abc1"])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE
) as p:
GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key")
p.assert_called_once_with(
json.dumps({
"collapse_key": "test_key",
"notification": {"body": "Hello world"},
"registration_ids": ["abc", "abc1"]
}, separators=(",", ":"), sort_keys=True).encode("utf-8"),
"application/json", application_id=None
)
def test_fcm_send_message_to_single_device_with_error(self):
# these errors are device specific, device.active will be set false
devices = ["abc", "abc1"]
self._create_fcm_devices(devices)
errors = [
responses.GCM_JSON_ERROR_NOTREGISTERED,
responses.GCM_JSON_ERROR_INVALIDREGISTRATION
]
for index, error in enumerate(errors):
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=error):
device = GCMDevice.objects.get(registration_id=devices[index])
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id=devices[index]).active is False
def test_fcm_send_message_to_single_device_with_error_mismatch(self):
device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM")
with mock.patch(
"push_notifications.gcm._fcm_send",
return_value=responses.GCM_JSON_ERROR_MISMATCHSENDERID
):
# these errors are not device specific, GCMError should be thrown
with self.assertRaises(GCMError):
device.send_message("Hello World!")
assert GCMDevice.objects.get(registration_id="abc").active is True
def test_fcm_send_message_to_multiple_devices_with_error(self):
self._create_fcm_devices(["abc", "abc1", "abc2"])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE_ERROR
):
devices = GCMDevice.objects.all()
devices.send_message("Hello World")
assert not GCMDevice.objects.get(registration_id="abc").active
assert GCMDevice.objects.get(registration_id="abc1").active
assert not GCMDevice.objects.get(registration_id="abc2").active
def test_fcm_send_message_to_multiple_devices_with_error_b(self):
self._create_fcm_devices(["abc", "abc1", "abc2"])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE_ERROR_B
):
devices = GCMDevice.objects.all()
with self.assertRaises(GCMError):
devices.send_message("Hello World")
assert GCMDevice.objects.get(registration_id="abc").active is True
assert GCMDevice.objects.get(registration_id="abc1").active is True
assert GCMDevice.objects.get(registration_id="abc2").active is False
def test_fcm_send_message_to_multiple_devices_with_canonical_id(self):
self._create_fcm_devices(["foo", "bar"])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_MULTIPLE_CANONICAL_ID
):
GCMDevice.objects.all().send_message("Hello World")
assert not GCMDevice.objects.filter(registration_id="foo").exists()
assert GCMDevice.objects.filter(registration_id="bar").exists()
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True
def test_fcm_send_message_to_single_user_with_canonical_id(self):
old_registration_id = "foo"
self._create_fcm_devices([old_registration_id])
with mock.patch(
"push_notifications.gcm._fcm_send", return_value=responses.GCM_JSON_CANONICAL_ID
):
GCMDevice.objects.get(registration_id=old_registration_id).send_message("Hello World")
assert not GCMDevice.objects.filter(registration_id=old_registration_id).exists()
assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists()
def test_fcm_send_message_to_same_devices_with_canonical_id(self):
first_device = GCMDevice.objects.create(
registration_id="foo", active=True, cloud_message_type="FCM"
)
second_device = GCMDevice.objects.create(
registration_id="bar", active=False, cloud_message_type="FCM"
)
with mock.patch(
"push_notifications.gcm._fcm_send",
return_value=responses.GCM_JSON_CANONICAL_ID_SAME_DEVICE
):
GCMDevice.objects.all().send_message("Hello World")
assert first_device.active is True
assert second_device.active is False
def test_fcm_send_message_with_no_reg_ids(self):
self._create_fcm_devices(["abc", "abc1"])
with mock.patch("push_notifications.gcm._cm_send_request", return_value="") as p:
GCMDevice.objects.filter(registration_id="xyz").send_message("Hello World")
p.assert_not_called()
with mock.patch("push_notifications.gcm._cm_send_request", return_value="") as p:
reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()]
send_bulk_message(reg_ids, {"message": "Hello World"}, "FCM")
p.assert_called_once_with(
[u"abc", u"abc1"], {"message": "Hello World"}, cloud_type="FCM",
application_id=None
)
def test_can_save_wsn_device(self):
device = GCMDevice.objects.create(registration_id="a valid registration id")
self.assertIsNotNone(device.pk)
self.assertIsNotNone(device.date_created)
self.assertEqual(device.date_created.date(), timezone.now().date())

View File

@ -1,52 +1,12 @@
from django.test import TestCase
from push_notifications.api.rest_framework import APNSDeviceSerializer, GCMDeviceSerializer
from rest_framework.serializers import ValidationError
from tests.mock_responses import GCM_DRF_INVALID_HEX_ERROR, GCM_DRF_OUT_OF_RANGE_ERROR
from push_notifications.api.rest_framework import (
GCMDeviceSerializer, ValidationError
)
class APNSDeviceSerializerTestCase(TestCase):
def test_validation(self):
# valid data - 32 bytes upper case
serializer = APNSDeviceSerializer(data={
"registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE",
"name": "Apple iPhone 6+",
"device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
})
self.assertTrue(serializer.is_valid())
# valid data - 32 bytes lower case
serializer = APNSDeviceSerializer(data={
"registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae",
"name": "Apple iPhone 6+",
"device_id": "ffffffffffffffffffffffffffffffff",
})
self.assertTrue(serializer.is_valid())
# valid data - 100 bytes upper case
serializer = APNSDeviceSerializer(data={
"registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE",
"name": "Apple iPhone 6+",
"device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
})
self.assertTrue(serializer.is_valid())
# valid data - 100 bytes lower case
serializer = APNSDeviceSerializer(data={
"registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae",
"name": "Apple iPhone 6+",
"device_id": "ffffffffffffffffffffffffffffffff",
})
self.assertTrue(serializer.is_valid())
# invalid data - device_id, registration_id
serializer = APNSDeviceSerializer(data={
"registration_id": "invalid device token contains no hex",
"name": "Apple iPhone 6+",
"device_id": "ffffffffffffffffffffffffffffake",
})
self.assertFalse(serializer.is_valid())
self.assertEqual(serializer.errors["device_id"][0], '"ffffffffffffffffffffffffffffake" is not a valid UUID.')
self.assertEqual(serializer.errors["registration_id"][0], "Registration ID (device token) is invalid")
GCM_DRF_INVALID_HEX_ERROR = {"device_id": [u"Device ID is not a valid hex number"]}
GCM_DRF_OUT_OF_RANGE_ERROR = {"device_id": [u"Device ID is out of range"]}
class GCMDeviceSerializerTestCase(TestCase):
@ -55,6 +15,7 @@ class GCMDeviceSerializerTestCase(TestCase):
"registration_id": "foobar",
"name": "Galaxy Note 3",
"device_id": "0x1031af3b",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
self.assertTrue(serializer.is_valid())
@ -66,6 +27,7 @@ class GCMDeviceSerializerTestCase(TestCase):
"registration_id": "foobar",
"name": "Galaxy Note 3",
"device_id": "0x1031af3b",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
serializer.is_valid(raise_exception=True)
obj = serializer.save()
@ -75,6 +37,7 @@ class GCMDeviceSerializerTestCase(TestCase):
"registration_id": "foobar",
"name": "Galaxy Note 5",
"device_id": "0x1031af3b",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
serializer.is_valid(raise_exception=True)
obj = serializer.save()
@ -84,17 +47,18 @@ class GCMDeviceSerializerTestCase(TestCase):
"registration_id": "foobar",
"name": "Galaxy Note 3",
"device_id": "0xdeadbeaf",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
with self.assertRaises(ValidationError) as ex:
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
self.assertEqual({'registration_id': [u'This field must be unique.']}, ex.exception.detail)
def test_device_id_validation_fail_bad_hex(self):
serializer = GCMDeviceSerializer(data={
"registration_id": "foobar",
"name": "Galaxy Note 3",
"device_id": "0x10r",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
self.assertFalse(serializer.is_valid())
self.assertEqual(serializer.errors, GCM_DRF_INVALID_HEX_ERROR)
@ -103,7 +67,8 @@ class GCMDeviceSerializerTestCase(TestCase):
serializer = GCMDeviceSerializer(data={
"registration_id": "foobar",
"name": "Galaxy Note 3",
"device_id": "10000000000000000", # 2**64
"device_id": "10000000000000000", # 2**64
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
self.assertFalse(serializer.is_valid())
self.assertEqual(serializer.errors, GCM_DRF_OUT_OF_RANGE_ERROR)
@ -116,5 +81,6 @@ class GCMDeviceSerializerTestCase(TestCase):
"registration_id": "foobar",
"name": "Nexus 5",
"device_id": "e87a4e72d634997c",
"application_id": "XXXXXXXXXXXXXXXXXXXX",
})
self.assertTrue(serializer.is_valid())

175
tests/test_wns.py Normal file
View File

@ -0,0 +1,175 @@
import xml.etree.ElementTree as ET
from django.test import TestCase
from push_notifications.wns import (
dict_to_xml_schema, wns_send_bulk_message, wns_send_message
)
from ._mock import mock
class WNSSendMessageTestCase(TestCase):
def setUp(self):
pass
@mock.patch("push_notifications.wns._wns_prepare_toast", return_value="this is expected")
@mock.patch("push_notifications.wns._wns_send")
def test_send_message_calls_wns_send_with_toast(self, mock_method, _):
wns_send_message(uri="one", message="test message")
mock_method.assert_called_with(
application_id=None, uri="one", data="this is expected", wns_type="wns/toast"
)
@mock.patch("push_notifications.wns._wns_prepare_toast", return_value="this is expected")
@mock.patch("push_notifications.wns._wns_send")
def test_send_message_calls_wns_send_with_application_id(self, mock_method, _):
wns_send_message(uri="one", message="test message", application_id="123456")
mock_method.assert_called_with(
application_id="123456", uri="one", data="this is expected", wns_type="wns/toast"
)
@mock.patch("push_notifications.wns.dict_to_xml_schema", return_value=ET.Element("toast"))
@mock.patch("push_notifications.wns._wns_send")
def test_send_message_calls_wns_send_with_xml(self, mock_method, _):
wns_send_message(uri="one", xml_data={"key": "value"})
mock_method.assert_called_with(
application_id=None, uri="one", data=b"<toast />", wns_type="wns/toast"
)
def test_send_message_raises_TypeError_if_one_of_the_data_params_arent_filled(self):
with self.assertRaises(TypeError):
wns_send_message(uri="one")
class WNSSendBulkMessageTestCase(TestCase):
def setUp(self):
pass
@mock.patch("push_notifications.wns.wns_send_message")
def test_send_bulk_message_doesnt_call_send_message_with_empty_list(self, mock_method):
wns_send_bulk_message(uri_list=[], message="test message")
mock_method.assert_not_called()
@mock.patch("push_notifications.wns.wns_send_message")
def test_send_bulk_message_calls_send_message(self, mock_method):
wns_send_bulk_message(uri_list=["one", ], message="test message")
mock_method.assert_called_with(
application_id=None, message="test message", raw_data=None, uri="one", xml_data=None
)
class WNSDictToXmlSchemaTestCase(TestCase):
def setUp(self):
pass
def test_create_simple_xml_from_dict(self):
xml_data = {
"toast": {
"attrs": {"key": "value"},
"children": {
"visual": {
"children": {
"binding": {
"attrs": {"template": "ToastText01"},
"children": {
"text": {
"attrs": {"id": "1"},
"children": "toast notification"
}
}
}
}
}
}
}
}
# Converting xml to str via tostring is inconsistent, so we have to check each element.
xml_tree = dict_to_xml_schema(xml_data)
self.assertEqual(xml_tree.tag, "toast")
self.assertEqual(xml_tree.attrib, {"key": "value"})
visual = xml_tree.getchildren()[0]
self.assertEqual(visual.tag, "visual")
binding = visual.getchildren()[0]
self.assertEqual(binding.tag, "binding")
self.assertEqual(binding.attrib, {"template": "ToastText01"})
text = binding.getchildren()[0]
self.assertEqual(text.tag, "text")
self.assertEqual(text.attrib, {"id": "1"})
self.assertEqual(text.text, "toast notification")
def test_create_multi_sub_element_xml_from_dict(self):
xml_data = {
"toast": {
"attrs": {
"key": "value"
},
"children": {
"visual": {
"children": {
"binding": {
"attrs": {"template": "ToastText02"},
"children": {
"text": [
{"attrs": {"id": "1"}, "children": "first text"},
{"attrs": {"id": "2"}, "children": "second text"},
]
}
}
}
}
}
}
}
# Converting xml to str via tostring is inconsistent, so we have to check each element.
xml_tree = dict_to_xml_schema(xml_data)
self.assertEqual(xml_tree.tag, "toast")
self.assertEqual(xml_tree.attrib, {"key": "value"})
visual = xml_tree.getchildren()[0]
self.assertEqual(visual.tag, "visual")
binding = visual.getchildren()[0]
self.assertEqual(binding.tag, "binding")
self.assertEqual(binding.attrib, {"template": "ToastText02"})
children = binding.getchildren()
self.assertEqual(len(children), 2)
def test_create_two_multi_sub_element_xml_from_dict(self):
xml_data = {
"toast": {
"attrs": {
"key": "value"
},
"children": {
"visual": {
"children": {
"binding": {
"attrs": {
"template": "ToastText02"
},
"children": {
"text": [
{"attrs": {"id": "1"}, "children": "first text"},
{"attrs": {"id": "2"}, "children": "second text"},
],
"image": [
{"attrs": {"src": "src1"}},
{"attrs": {"src": "src2"}},
]
}
}
}
}
}
}
}
# Converting xml to str via tostring is inconsistent, so we have to check each element.
xml_tree = dict_to_xml_schema(xml_data)
self.assertEqual(xml_tree.tag, "toast")
self.assertEqual(xml_tree.attrib, {"key": "value"})
visual = xml_tree.getchildren()[0]
self.assertEqual(visual.tag, "visual")
binding = visual.getchildren()[0]
self.assertEqual(binding.tag, "binding")
self.assertEqual(binding.attrib, {"template": "ToastText02"})
children = binding.getchildren()
self.assertEqual(len(children), 4)

50
tox.ini
View File

@ -1,20 +1,44 @@
[tox]
envlist = {py27,py34,py35}--django{18,19}--drf{32,33},flake8
envlist =
py27-django111
py34-django{111}
py36-django{111,20,master}
flake8
[testenv]
commands = python ./tests/runtests.py
deps=
django18: Django>=1.8,<1.9
django19: Django>=1.9,<2.0
mock==1.0.1
drf32: djangorestframework>=3.2,<3.3
drf33: djangorestframework>=3.3,<3.4
setenv =
PYTHONWARNINGS = all
DJANGO_SETTINGS_MODULE = tests.settings
PYTHONPATH = {toxinidir}
commands = pytest
deps =
pytest
pytest-django
mock
djangorestframework>=3.5
django111: Django>=1.11,<2.0
django20: Django>=2.0,<2.1
djangomaster: https://github.com/django/django/archive/master.tar.gz
[testenv:flake8]
commands = flake8 push_notifications
deps = flake8
commands = flake8
deps =
flake8
flake8-isort
flake8-quotes
[flake8]
ignore = F403,W191,E126,E128
max-line-length = 160
exclude = push_notifications/migrations/*
ignore = W191
max-line-length = 92
exclude = .tox, push_notifications/migrations
inline-quotes = double
[isort]
indent = tab
line_length = 92
lines_after_imports = 2
balanced_wrapping = True
default_section = THIRDPARTY
known_first_party = push_notifications
multi_line_output = 5
skip = .tox/