├── requirements.txt ├── push_notifications ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── prune_devices.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20160106_0850.py │ └── 0001_initial.py ├── __init__.py ├── api │ ├── __init__.py │ ├── tastypie.py │ └── rest_framework.py ├── settings.py ├── admin.py ├── models.py ├── fields.py ├── gcm.py └── apns.py ├── setup.cfg ├── MANIFEST.in ├── .gitignore ├── .travis.yml ├── tests ├── __init__.py ├── settings.py ├── test_management_commands.py ├── test_gcm_push_payload.py ├── runtests.py ├── test_apns_push_payload.py ├── mock_responses.py ├── test_data │ ├── without_private.pem │ ├── good_with_passwd.pem │ └── good_revoked.pem ├── test_apns_certfilecheck.py ├── test_rest_framework.py └── test_models.py ├── tox.ini ├── AUTHORS ├── LICENSE ├── CONTRIBUTING.md ├── setup.py ├── CHANGELOG.rst └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | -------------------------------------------------------------------------------- /push_notifications/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /push_notifications/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /push_notifications/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /push_notifications/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __author__ = "Jerome Leclanche" 3 | __email__ = "jerome@leclan.ch" 4 | __version__ = "1.4.1" 5 | 6 | 7 | class NotificationError(Exception): 8 | pass 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python compiled 2 | __pycache__ 3 | *.pyc 4 | 5 | # distutils 6 | MANIFEST 7 | build 8 | 9 | # IDE 10 | .idea 11 | *.iml 12 | 13 | # virtualenv 14 | .env 15 | 16 | # tox 17 | .tox 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | addons: 3 | apt: 4 | sources: 5 | - deadsnakes 6 | packages: 7 | - python3.5 8 | install: 9 | - pip install tox 10 | script: 11 | - tox 12 | sudo: false 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from test_models import * 2 | from test_gcm_push_payload import * 3 | from test_apns_push_payload import * 4 | from test_management_commands import * 5 | from test_apns_certfilecheck import * 6 | 7 | # conditionally test rest_framework api if the DRF package is installed 8 | try: 9 | import rest_framework 10 | except ImportError: 11 | pass 12 | else: 13 | from test_rest_framework import * 14 | -------------------------------------------------------------------------------- /push_notifications/api/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | if "tastypie" in settings.INSTALLED_APPS: 4 | # Tastypie resources are importable from the api package level (backwards compatibility) 5 | from .tastypie import APNSDeviceResource, GCMDeviceResource, APNSDeviceAuthenticatedResource, GCMDeviceAuthenticatedResource 6 | 7 | __all__ = [ 8 | "APNSDeviceResource", 9 | "GCMDeviceResource", 10 | "APNSDeviceAuthenticatedResource", 11 | "GCMDeviceAuthenticatedResource" 12 | ] 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py27,py34,py35}--django{18,19}--drf{32,33},flake8 3 | 4 | [testenv] 5 | commands = python ./tests/runtests.py 6 | deps= 7 | django18: Django>=1.8,<1.9 8 | django19: Django>=1.9,<2.0 9 | mock==1.0.1 10 | drf32: djangorestframework>=3.2,<3.3 11 | drf33: djangorestframework>=3.3,<3.4 12 | 13 | [testenv:flake8] 14 | commands = flake8 push_notifications 15 | deps = flake8 16 | 17 | [flake8] 18 | ignore = F403,W191,E126,E128 19 | max-line-length = 160 20 | exclude = push_notifications/migrations/* 21 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # assert warnings are enabled 2 | import warnings 3 | warnings.simplefilter("ignore", Warning) 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | } 9 | } 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "push_notifications", 18 | ] 19 | 20 | SITE_ID = 1 21 | ROOT_URLCONF = "core.urls" 22 | 23 | SECRET_KEY = "foobar" 24 | 25 | PUSH_NOTIFICATIONS_SETTINGS = { 26 | } -------------------------------------------------------------------------------- /push_notifications/migrations/0002_auto_20160106_0850.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.1 on 2016-01-06 08:50 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('push_notifications', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='apnsdevice', 17 | name='registration_id', 18 | field=models.CharField(max_length=200, unique=True, verbose_name='Registration ID'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /push_notifications/management/commands/prune_devices.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | 4 | class Command(BaseCommand): 5 | can_import_settings = True 6 | help = 'Deactivate APNS devices that are not receiving notifications' 7 | 8 | def handle(self, *args, **options): 9 | from push_notifications.models import APNSDevice, get_expired_tokens 10 | expired = get_expired_tokens() 11 | devices = APNSDevice.objects.filter(registration_id__in=expired) 12 | for d in devices: 13 | self.stdout.write('deactivating [%s]' % d.registration_id) 14 | d.active = False 15 | d.save() 16 | self.stdout.write('deactivated %d devices' % len(devices)) 17 | -------------------------------------------------------------------------------- /tests/test_management_commands.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from django.core.management import call_command 4 | 5 | from django.test import TestCase 6 | from push_notifications.apns import _apns_send, APNSDataOverflow 7 | 8 | 9 | class CommandsTestCase(TestCase): 10 | 11 | def test_prune_devices(self): 12 | from push_notifications.models import APNSDevice 13 | 14 | device = APNSDevice.objects.create( 15 | registration_id="616263", # hex encoding of b'abc' 16 | ) 17 | with mock.patch( 18 | 'push_notifications.apns._apns_create_socket_to_feedback', 19 | mock.MagicMock()): 20 | with mock.patch('push_notifications.apns._apns_receive_feedback', 21 | mock.MagicMock()) as receiver: 22 | receiver.side_effect = lambda s: [(b'', b'abc')] 23 | call_command('prune_devices') 24 | device = APNSDevice.objects.get(pk=device.pk) 25 | self.assertFalse(device.active) 26 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | This library was created by Jerome Leclanche , for use on the 2 | Anthill application (https://www.anthill.com). 3 | 4 | Special thanks to everyone who contributed: 5 | 6 | Adam "Cezar" Jenkins 7 | Alan Descoins 8 | Ales Dokhanin 9 | Alistair Broomhead 10 | Andrey Zevakin 11 | Antonin Lenfant 12 | Arthur Silva 13 | Avichal Pandey 14 | Brad Pitcher 15 | Daniel Kronovet 16 | David Pretty 17 | Dilvane Zanardine 18 | Florian Finke 19 | Florian Purchess 20 | Francois Lebel 21 | halak 22 | Innocenty Enikeew 23 | Jack Feng 24 | Jamaal Scarlett 25 | Jay Camp 26 | Jeremy Morgan 27 | Jerome Leclanche 28 | Julien Dubiel 29 | Lital Natan 30 | Luke Burden 31 | Marconi Moreto 32 | Matthew Hershberger 33 | Maxim Kamenkov 34 | Mohamad Nour Chawich 35 | Nicolas Delaby 36 | Remigiusz Dymecki 37 | Ruslan Kovtun 38 | Sander Heling 39 | Sergei Evdokimov 40 | Sujit Nair 41 | Thomas Iovine 42 | Valentin Hăloiu 43 | Wyan Jow 44 | @hoongun 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Jerome Leclanche 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /push_notifications/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | PUSH_NOTIFICATIONS_SETTINGS = getattr(settings, "PUSH_NOTIFICATIONS_SETTINGS", {}) 4 | 5 | 6 | # GCM 7 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_POST_URL", "https://android.googleapis.com/gcm/send") 8 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_MAX_RECIPIENTS", 1000) 9 | 10 | 11 | # APNS 12 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_PORT", 2195) 13 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_PORT", 2196) 14 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_ERROR_TIMEOUT", None) 15 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_MAX_NOTIFICATION_SIZE", 2048) 16 | if settings.DEBUG: 17 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.sandbox.push.apple.com") 18 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.sandbox.push.apple.com") 19 | else: 20 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.push.apple.com") 21 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.push.apple.com") 22 | 23 | 24 | # User model 25 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("USER_MODEL", settings.AUTH_USER_MODEL) 26 | -------------------------------------------------------------------------------- /tests/test_gcm_push_payload.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import json 3 | from django.test import TestCase 4 | from push_notifications.gcm import gcm_send_message, gcm_send_bulk_message 5 | from tests.mock_responses import GCM_PLAIN_RESPONSE, GCM_JSON_RESPONSE 6 | 7 | 8 | class GCMPushPayloadTest(TestCase): 9 | def test_push_payload(self): 10 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: 11 | gcm_send_message("abc", {"message": "Hello world"}) 12 | p.assert_called_once_with( 13 | b"data.message=Hello+world®istration_id=abc", 14 | "application/x-www-form-urlencoded;charset=UTF-8") 15 | 16 | def test_push_payload_params(self): 17 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: 18 | gcm_send_message("abc", {"message": "Hello world"}, delay_while_idle=True, time_to_live=3600) 19 | p.assert_called_once_with( 20 | b"data.message=Hello+world&delay_while_idle=1®istration_id=abc&time_to_live=3600", 21 | "application/x-www-form-urlencoded;charset=UTF-8") 22 | 23 | def test_bulk_push_payload(self): 24 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON_RESPONSE) as p: 25 | gcm_send_bulk_message(["abc", "123"], {"message": "Hello world"}) 26 | p.assert_called_once_with( 27 | b'{"data":{"message":"Hello world"},"registration_ids":["abc","123"]}', 28 | "application/json") 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Coding style 2 | 3 | Please adhere to the coding style throughout the project. 4 | 5 | 1. Always use tabs. [Here](https://leclan.ch/tabs) is a short explanation why tabs are preferred. 6 | 2. Always use double quotes for strings, unless single quotes avoid unnecessary escapes. 7 | 3. When in doubt, [PEP8](https://www.python.org/dev/peps/pep-0008/). Follow its naming conventions. 8 | 4. Know when to make exceptions. 9 | 10 | Also see: [How to name things in programming](http://www.slideshare.net/pirhilton/how-to-name-things-the-hardest-problem-in-programming) 11 | 12 | 13 | ### Commits and Pull Requests 14 | 15 | Keep the commit log as healthy as the code. It is one of the first places new contributors will look at the project. 16 | 17 | 1. No more than one change per commit. There should be no changes in a commit which are unrelated to its message. 18 | 2. Every commit should pass all tests on its own. 19 | 3. Follow [these conventions](http://chris.beams.io/posts/git-commit/) when writing the commit message 20 | 21 | When filing a Pull Request, make sure it is rebased on top of most recent master. 22 | 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. 23 | 24 | Also see: [Github Help: Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 25 | -------------------------------------------------------------------------------- /tests/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import unittest 5 | 6 | 7 | def setup(): 8 | """ 9 | set up test environment 10 | """ 11 | 12 | # add test/src folders to sys path 13 | test_folder = os.path.abspath(os.path.dirname(__file__)) 14 | src_folder = os.path.abspath(os.path.join(test_folder, os.pardir)) 15 | sys.path.insert(0, test_folder) 16 | sys.path.insert(0, src_folder) 17 | 18 | # define settings 19 | import django.conf 20 | os.environ[django.conf.ENVIRONMENT_VARIABLE] = "settings" 21 | 22 | # set up environment 23 | from django.test.utils import setup_test_environment 24 | setup_test_environment() 25 | 26 | # See https://docs.djangoproject.com/en/dev/releases/1.7/#app-loading-changes 27 | import django 28 | if django.VERSION >= (1, 7, 0): 29 | django.setup() 30 | 31 | # set up database 32 | from django.db import connection 33 | connection.creation.create_test_db() 34 | 35 | 36 | def tear_down(): 37 | """ 38 | tear down test environment 39 | """ 40 | 41 | # destroy test database 42 | from django.db import connection 43 | connection.creation.destroy_test_db("not_needed") 44 | 45 | # teardown environment 46 | from django.test.utils import teardown_test_environment 47 | teardown_test_environment() 48 | 49 | 50 | # fire in the hole! 51 | if __name__ == "__main__": 52 | setup() 53 | 54 | import tests 55 | unittest.main(module=tests) 56 | 57 | tear_down() 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os.path 3 | import push_notifications 4 | from distutils.core import setup 5 | 6 | README = open(os.path.join(os.path.dirname(__file__), "README.rst")).read() 7 | 8 | CLASSIFIERS = [ 9 | "Development Status :: 5 - Production/Stable", 10 | "Environment :: Web Environment", 11 | "Framework :: Django", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 2.7", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.4", 18 | "Programming Language :: Python :: 3.5", 19 | "Topic :: Software Development :: Libraries :: Python Modules", 20 | "Topic :: System :: Networking", 21 | ] 22 | 23 | setup( 24 | name="django-push-notifications", 25 | packages=[ 26 | "push_notifications", 27 | "push_notifications/api", 28 | "push_notifications/migrations", 29 | "push_notifications/management", 30 | "push_notifications/management/commands", 31 | ], 32 | author=push_notifications.__author__, 33 | author_email=push_notifications.__email__, 34 | classifiers=CLASSIFIERS, 35 | description="Send push notifications to mobile devices through GCM or APNS in Django.", 36 | download_url="https://github.com/jleclanche/django-push-notifications/tarball/master", 37 | long_description=README, 38 | url="https://github.com/jleclanche/django-push-notifications", 39 | version=push_notifications.__version__, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test_apns_push_payload.py: -------------------------------------------------------------------------------- 1 | import mock 2 | from django.test import TestCase 3 | from push_notifications.apns import _apns_send, APNSDataOverflow 4 | 5 | 6 | class APNSPushPayloadTest(TestCase): 7 | def test_push_payload(self): 8 | socket = mock.MagicMock() 9 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 10 | _apns_send("123", "Hello world", 11 | badge=1, sound="chime", extra={"custom_data": 12345}, expiration=3, socket=socket) 12 | p.assert_called_once_with("123", 13 | b'{"aps":{"alert":"Hello world","badge":1,"sound":"chime"},"custom_data":12345}', 0, 3, 10) 14 | 15 | def test_localised_push_with_empty_body(self): 16 | socket = mock.MagicMock() 17 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 18 | _apns_send("123", None, loc_key="TEST_LOC_KEY", expiration=3, socket=socket) 19 | p.assert_called_once_with("123", b'{"aps":{"alert":{"loc-key":"TEST_LOC_KEY"}}}', 0, 3, 10) 20 | 21 | def test_using_extra(self): 22 | socket = mock.MagicMock() 23 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 24 | _apns_send("123", "sample", extra={"foo": "bar"}, identifier=10, expiration=30, priority=10, socket=socket) 25 | p.assert_called_once_with("123", b'{"aps":{"alert":"sample"},"foo":"bar"}', 10, 30, 10) 26 | 27 | def test_oversized_payload(self): 28 | socket = mock.MagicMock() 29 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 30 | self.assertRaises(APNSDataOverflow, _apns_send, "123", "_" * 2049, socket=socket) 31 | p.assert_has_calls([]) 32 | -------------------------------------------------------------------------------- /push_notifications/api/tastypie.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from tastypie.authorization import Authorization 4 | from tastypie.authentication import BasicAuthentication 5 | from tastypie.resources import ModelResource 6 | from push_notifications.models import APNSDevice, GCMDevice 7 | 8 | 9 | class APNSDeviceResource(ModelResource): 10 | class Meta: 11 | authorization = Authorization() 12 | queryset = APNSDevice.objects.all() 13 | resource_name = "device/apns" 14 | 15 | 16 | class GCMDeviceResource(ModelResource): 17 | class Meta: 18 | authorization = Authorization() 19 | queryset = GCMDevice.objects.all() 20 | resource_name = "device/gcm" 21 | 22 | 23 | class APNSDeviceAuthenticatedResource(APNSDeviceResource): 24 | # user = ForeignKey(UserResource, "user") 25 | 26 | class Meta(APNSDeviceResource.Meta): 27 | authentication = BasicAuthentication() 28 | # authorization = SameUserAuthorization() 29 | 30 | def obj_create(self, bundle, **kwargs): 31 | # See https://github.com/toastdriven/django-tastypie/issues/854 32 | return super(APNSDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs) 33 | 34 | 35 | class GCMDeviceAuthenticatedResource(GCMDeviceResource): 36 | # user = ForeignKey(UserResource, "user") 37 | 38 | class Meta(GCMDeviceResource.Meta): 39 | authentication = BasicAuthentication() 40 | # authorization = SameUserAuthorization() 41 | 42 | def obj_create(self, bundle, **kwargs): 43 | # See https://github.com/toastdriven/django-tastypie/issues/854 44 | return super(GCMDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs) 45 | -------------------------------------------------------------------------------- /tests/mock_responses.py: -------------------------------------------------------------------------------- 1 | GCM_PLAIN_RESPONSE = 'id=1:08' 2 | GCM_JSON_RESPONSE = '{"multicast_id":108,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"1:08"}]}' 3 | GCM_MULTIPLE_JSON_RESPONSE = ('{"multicast_id":108,"success":2,"failure":0,"canonical_ids":0,"results":' 4 | '[{"message_id":"1:08"}, {"message_id": "1:09"}]}') 5 | GCM_PLAIN_RESPONSE_ERROR = ['Error=NotRegistered', 'Error=InvalidRegistration'] 6 | GCM_PLAIN_RESPONSE_ERROR_B = 'Error=MismatchSenderId' 7 | GCM_PLAIN_CANONICAL_ID_RESPONSE = "id=1:2342\nregistration_id=NEW_REGISTRATION_ID" 8 | GCM_JSON_RESPONSE_ERROR = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":' 9 | ' [{"error": "NotRegistered"}, {"message_id": "0:1433830664381654%3449593ff9fd7ecd"}, ' 10 | '{"error": "InvalidRegistration"}]}') 11 | GCM_JSON_RESPONSE_ERROR_B = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, ' 12 | '"results": [{"error": "MismatchSenderId"}, {"message_id": ' 13 | '"0:1433830664381654%3449593ff9fd7ecd"}, {"error": "InvalidRegistration"}]}') 14 | GCM_DRF_INVALID_HEX_ERROR = {'device_id': [u"Device ID is not a valid hex number"]} 15 | GCM_DRF_OUT_OF_RANGE_ERROR = {'device_id': [u"Device ID is out of range"]} 16 | 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"}]}' 17 | 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"}]}' 18 | -------------------------------------------------------------------------------- /tests/test_data/without_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 3 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 4 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 5 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 6 | HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk 7 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 8 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 9 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 10 | AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp 11 | yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV 12 | 72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg 13 | /hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif 14 | u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0 15 | EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh 16 | MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud 17 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 18 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 19 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 20 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 21 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 22 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 23 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 24 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 25 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 26 | ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7 27 | Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE 28 | 6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG 29 | hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO 30 | 0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe 31 | 7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A== 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /tests/test_apns_certfilecheck.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mock 3 | import os 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | from push_notifications.models import APNSDevice 7 | 8 | from django.conf import settings 9 | 10 | from push_notifications.apns import * 11 | 12 | class APNSCertfileTestCase(TestCase): 13 | def test_apns_send_message_good_certfile(self): 14 | path = os.path.join(os.path.dirname(__file__),"test_data","good_revoked.pem") 15 | settings.PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] = path 16 | device = APNSDevice.objects.create( 17 | registration_id="1212121212121212121212121212121212121212121212121212121212121212", 18 | ) 19 | with mock.patch("ssl.wrap_socket") as ws: 20 | with mock.patch("socket.socket") as socket: 21 | socket.return_value = 123 22 | device.send_message("Hello world") 23 | ws.assert_called_once_with(123, ca_certs=None, certfile=path, ssl_version=3) 24 | 25 | def test_apns_send_message_raises_no_privatekey(self): 26 | path = os.path.join(os.path.dirname(__file__),"test_data","without_private.pem") 27 | settings.PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] = path 28 | device = APNSDevice.objects.create( 29 | registration_id="1212121212121212121212121212121212121212121212121212121212121212", 30 | ) 31 | with self.assertRaises(ImproperlyConfigured) as ic: 32 | device.send_message("Hello world") 33 | self.assertTrue(bool(ic.exception.args)) 34 | self.assertEqual(ic.exception.args[0],"The APNS certificate file at '%s' is unusable: The certificate doesn't contain a private key" % path) 35 | 36 | def test_apns_send_message_raises_passwd(self): 37 | path = os.path.join(os.path.dirname(__file__),"test_data","good_with_passwd.pem") 38 | settings.PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] = path 39 | device = APNSDevice.objects.create( 40 | registration_id="1212121212121212121212121212121212121212121212121212121212121212", 41 | ) 42 | with self.assertRaises(ImproperlyConfigured) as ic: 43 | device.send_message("Hello world") 44 | self.assertTrue(bool(ic.exception.args)) 45 | self.assertEqual(ic.exception.args[0],"The APNS certificate file at '%s' is unusable: The certificate private key should not be encrypted" % path) 46 | -------------------------------------------------------------------------------- /push_notifications/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import push_notifications.fields 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='APNSDevice', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), 21 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 22 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), 23 | ('device_id', models.UUIDField(help_text='UDID / UIDevice.identifierForVendor()', max_length=32, null=True, verbose_name='Device ID', blank=True, db_index=True)), 24 | ('registration_id', models.CharField(unique=True, max_length=64, verbose_name='Registration ID')), 25 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), 26 | ], 27 | options={ 28 | 'verbose_name': 'APNS device', 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | migrations.CreateModel( 33 | name='GCMDevice', 34 | fields=[ 35 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 36 | ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), 37 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 38 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), 39 | ('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)), 40 | ('registration_id', models.TextField(verbose_name='Registration ID')), 41 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), 42 | ], 43 | options={ 44 | 'verbose_name': 'GCM device', 45 | }, 46 | bases=(models.Model,), 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /push_notifications/admin.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib import admin, messages 3 | from django.utils.translation import ugettext_lazy as _ 4 | from .gcm import GCMError 5 | from .models import APNSDevice, GCMDevice, get_expired_tokens 6 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 7 | 8 | 9 | User = apps.get_model(*SETTINGS["USER_MODEL"].split(".")) 10 | 11 | 12 | class DeviceAdmin(admin.ModelAdmin): 13 | list_display = ("__str__", "device_id", "user", "active", "date_created") 14 | list_filter = ("active", ) 15 | actions = ("send_message", "send_bulk_message", "prune_devices", "enable", "disable") 16 | raw_id_fields = ("user", ) 17 | 18 | if hasattr(User, "USERNAME_FIELD"): 19 | search_fields = ("name", "device_id", "user__%s" % (User.USERNAME_FIELD)) 20 | else: 21 | search_fields = ("name", "device_id") 22 | 23 | def send_messages(self, request, queryset, bulk=False): 24 | """ 25 | Provides error handling for DeviceAdmin send_message and send_bulk_message methods. 26 | """ 27 | ret = [] 28 | errors = [] 29 | r = "" 30 | 31 | for device in queryset: 32 | try: 33 | if bulk: 34 | r = queryset.send_message("Test bulk notification") 35 | else: 36 | r = device.send_message("Test single notification") 37 | if r: 38 | ret.append(r) 39 | except GCMError as e: 40 | errors.append(str(e)) 41 | 42 | if bulk: 43 | break 44 | 45 | if errors: 46 | self.message_user(request, _("Some messages could not be processed: %r" % (", ".join(errors))), level=messages.ERROR) 47 | if ret: 48 | if not bulk: 49 | ret = ", ".join(ret) 50 | if errors: 51 | msg = _("Some messages were sent: %s" % (ret)) 52 | else: 53 | msg = _("All messages were sent: %s" % (ret)) 54 | self.message_user(request, msg) 55 | 56 | def send_message(self, request, queryset): 57 | self.send_messages(request, queryset) 58 | send_message.short_description = _("Send test message") 59 | 60 | def send_bulk_message(self, request, queryset): 61 | self.send_messages(request, queryset, True) 62 | send_bulk_message.short_description = _("Send test message in bulk") 63 | 64 | def enable(self, request, queryset): 65 | queryset.update(active=True) 66 | enable.short_description = _("Enable selected devices") 67 | 68 | def disable(self, request, queryset): 69 | queryset.update(active=False) 70 | disable.short_description = _("Disable selected devices") 71 | 72 | def prune_devices(self, request, queryset): 73 | # Note that when get_expired_tokens() is called, Apple's 74 | # feedback service resets, so, calling it again won't return 75 | # the device again (unless a message is sent to it again). So, 76 | # if the user doesn't select all the devices for pruning, we 77 | # could very easily leave an expired device as active. Maybe 78 | # this is just a bad API. 79 | expired = get_expired_tokens() 80 | devices = queryset.filter(registration_id__in=expired) 81 | for d in devices: 82 | d.active = False 83 | d.save() 84 | 85 | 86 | admin.site.register(APNSDevice, DeviceAdmin) 87 | admin.site.register(GCMDevice, DeviceAdmin) 88 | -------------------------------------------------------------------------------- /push_notifications/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | from django.utils.encoding import python_2_unicode_compatible 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | from .fields import HexIntegerField 8 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 9 | 10 | 11 | @python_2_unicode_compatible 12 | class Device(models.Model): 13 | name = models.CharField(max_length=255, verbose_name=_("Name"), blank=True, null=True) 14 | active = models.BooleanField(verbose_name=_("Is active"), default=True, 15 | help_text=_("Inactive devices will not be sent notifications")) 16 | user = models.ForeignKey(SETTINGS["USER_MODEL"], blank=True, null=True) 17 | date_created = models.DateTimeField(verbose_name=_("Creation date"), auto_now_add=True, null=True) 18 | 19 | class Meta: 20 | abstract = True 21 | 22 | def __str__(self): 23 | return self.name or \ 24 | str(self.device_id or "") or \ 25 | "%s for %s" % (self.__class__.__name__, self.user or "unknown user") 26 | 27 | 28 | class GCMDeviceManager(models.Manager): 29 | def get_queryset(self): 30 | return GCMDeviceQuerySet(self.model) 31 | 32 | 33 | class GCMDeviceQuerySet(models.query.QuerySet): 34 | def send_message(self, message, **kwargs): 35 | if self: 36 | from .gcm import gcm_send_bulk_message 37 | 38 | data = kwargs.pop("extra", {}) 39 | if message is not None: 40 | data["message"] = message 41 | 42 | reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True)) 43 | return gcm_send_bulk_message(registration_ids=reg_ids, data=data, **kwargs) 44 | 45 | 46 | class GCMDevice(Device): 47 | # device_id cannot be a reliable primary key as fragmentation between different devices 48 | # can make it turn out to be null and such: 49 | # http://android-developers.blogspot.co.uk/2011/03/identifying-app-installations.html 50 | device_id = HexIntegerField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 51 | help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)")) 52 | registration_id = models.TextField(verbose_name=_("Registration ID")) 53 | 54 | objects = GCMDeviceManager() 55 | 56 | class Meta: 57 | verbose_name = _("GCM device") 58 | 59 | def send_message(self, message, **kwargs): 60 | from .gcm import gcm_send_message 61 | data = kwargs.pop("extra", {}) 62 | if message is not None: 63 | data["message"] = message 64 | return gcm_send_message(registration_id=self.registration_id, data=data, **kwargs) 65 | 66 | 67 | class APNSDeviceManager(models.Manager): 68 | def get_queryset(self): 69 | return APNSDeviceQuerySet(self.model) 70 | 71 | 72 | class APNSDeviceQuerySet(models.query.QuerySet): 73 | def send_message(self, message, **kwargs): 74 | if self: 75 | from .apns import apns_send_bulk_message 76 | reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True)) 77 | return apns_send_bulk_message(registration_ids=reg_ids, alert=message, **kwargs) 78 | 79 | 80 | class APNSDevice(Device): 81 | device_id = models.UUIDField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 82 | help_text="UDID / UIDevice.identifierForVendor()") 83 | registration_id = models.CharField(verbose_name=_("Registration ID"), max_length=200, unique=True) 84 | 85 | objects = APNSDeviceManager() 86 | 87 | class Meta: 88 | verbose_name = _("APNS device") 89 | 90 | def send_message(self, message, **kwargs): 91 | from .apns import apns_send_message 92 | 93 | return apns_send_message(registration_id=self.registration_id, alert=message, **kwargs) 94 | 95 | 96 | # This is an APNS-only function right now, but maybe GCM will implement it 97 | # in the future. But the definition of 'expired' may not be the same. Whatevs 98 | def get_expired_tokens(): 99 | from .apns import apns_fetch_inactive_ids 100 | return apns_fetch_inactive_ids() 101 | -------------------------------------------------------------------------------- /tests/test_data/good_with_passwd.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFkTCCBHmgAwIBAgIIRc+fhlv8zowwDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 3 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 4 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 5 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 6 | HhcNMTUxMjE1MTIzMDE3WhcNMTYxMjE0MTIzMDE3WjCBkDEmMCQGCgmSJomT8ixk 7 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 8 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 9 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 10 | AQEBBQADggEPADCCAQoCggEBAJq6041XOdS4wTOT6UeWVKr6DqZFsYSTA8TFVyqT 11 | cZYc19KWi9gQ2NK+WwsoRxHMmtAdZxYMTecMlqD/B4r3aiNpMjZWV8x25ymjwlGa 12 | 2zLZJ6y05/j2YDAk5mNSCensQmKOB4aJ0MtCnCbONDY1GDlB1PXMqs9VsWkI+glC 13 | T4DF0PdF6cWqeR1SRm0vm32WHBX4RkMJp4QxE2jYDS0ENWTnkqOQ0JLLk2eb/2Lq 14 | Tk0/F7wemyOsmYpscSnuwtYM0zkl2un5eWQR0pzpBStvVQP7TWyQPmEnasIGccWK 15 | LBftpJTCvG9eJkJhyH9UtoKMFq7r58WfggdLb/mL9ZAf+7cCAwEAAaOCAeUwggHh 16 | MB0GA1UdDgQWBBTGL6K5Ta3vxjOLdQTBY/wDTMYpbTAJBgNVHRMEAjAAMB8GA1Ud 17 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 18 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 19 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 20 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 21 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 22 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 23 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 24 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 25 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 26 | ADANBgkqhkiG9w0BAQUFAAOCAQEAayrzBuIGSZnIMbF+DhAlWeJNNimNSFOWX7X8 27 | f3YKytp8F5LpvX1kvDuJwntyXgdBsziQYtMG/RweQ9DZSYnCEzWDCQFckn9F67Eb 28 | fj1ohidh+sPgfsuV8sx5Rm9wvCdg1jSSrqxnHMDxuReX+d8DjU9e2Z1fqd7wcEbk 29 | LJUWxBR7+0KGYOqqUa5OrGS7tYcZj0v7f3xJyqsYVFSfYk1h7WoTr11bCSdCV+0y 30 | zzeVLQB4RQLQt+LLb1OZlj5NdM73fSicTwy3ihFxvWPTDozxfCBvIgLT3PYJBc3k 31 | NonhJADFD84YUTCnZC/xreAbjY7ss90fKLzxJfzMB+Wskf/EKQ== 32 | -----END CERTIFICATE----- 33 | Bag Attributes 34 | friendlyName: CloudTrack Push 35 | localKeyID: C6 2F A2 B9 4D AD EF C6 33 8B 75 04 C1 63 FC 03 4C C6 29 6D 36 | Key Attributes: 37 | -----BEGIN RSA PRIVATE KEY----- 38 | Proc-Type: 4,ENCRYPTED 39 | DEK-Info: DES-EDE3-CBC,61A87CB1762663CC 40 | 41 | z8z9Q9eVhRazYHnvx1LOJtWp9v7UaX/YluJ8qFuH8QG1cRbn5wxYqz61ZECDNQIl 42 | bUYRW95QQ1GO8PpNzJ+0z0tyJ63TuzZvg1GlhGtDSCpQaRfS1SGypIYsejnEwsUN 43 | IppR63g4TJALeP80KFGetIGhvpUURiAhRV+HV44naMkUfExa12YJs6b7ZLRN4uDz 44 | JWl41t+h/nvXKgNtIyVQIMj6rTkrLNE0YQ8fgerc8L7XSYOg0mdpp3CyLn9rkrWI 45 | rCbxjHyudT+LzJZBr0KWJZ2FvJp3KGVGAtGhUJ3biuRqKw6syUlCSDEaRmYSiI4C 46 | GvINoBMag7nXS6lbsEgpS8+N43tT13uxmzNDax/5ASMXuslFaD/s2GbcUXxWv2YL 47 | +GybNO83C8TzUDavWEzUBcbWdboim+Rh2HELPFNt1fEUzyj7ekqboT5YTQ4ceLJY 48 | dgjM9kNCKYum8Gfy5gfXPSwIGOKPo6hssHMEOVjDLM3169POfRc11KWIU4NEGZP8 49 | 4CML5mrYdP/y3KziPDyXRUvlwGNJh9mr7ucqyjfLd5fBrYZit2jE2zlD8H8UQf1F 50 | 0VMmw6Szc6pimxhLOqXu1jHfCvP9s9w5dY8s2MFKS5trevsXNI5RzDuF2cBlGk0l 51 | 3x3akNkq10jiqsN/v+BWpmhEMhf46/BqLDGIXBsDGkqVpjunJ9Hn+lc4Fkwtq73d 52 | LSLcPit3WRifgd9NX5BJKoSEalyCHnsteWFteS+W3J1lEnH1E8Vri6V5aOefwcFI 53 | lDn34XTB/huzi31p6301vhGftI4+qcYVm322TcSvyMR4jwZ28UCMrgFa+RH4Xn4n 54 | W0OmbaDjDzwvXkh9RlgTyuLQNR64ZVb27kYsUGumoNmg7DbpmM6PCaTTKyMw6Tgo 55 | CvGZ/cdpGOcgwFVM40HaIFsH12QIUeAkepHWuzkvhUlAv9mAOZtgV8q5er0kPPBs 56 | AgTIqCWJtlkU54HGdToR59TlENKAVxO2+v98T8I28NvduMYiwP94Ihfd/pto1tAg 57 | Ovwb3HudRNuGO/IrQokTY9B7yyldZ9YIC5suJwQ+1M06HK3D4E81GsfifQvUoZjD 58 | 4foEf+gEdHt3ayUk98oHw9k/LNKkZhRviBHvFR7NnCTY77EX4LfHR0E3h2DzWIU+ 59 | oGC8InbtN9eV8o05SiRusM3zGK9qn3nHmw/KjjO6K+FxwnoaKHYYOyL1Xdu/CJGR 60 | 0+vLKqIUTOoVK0Ox3yj2zaJWpm5rgKdTxhCoopS4LoHP+J7h24LwxcCk/rVTd6o5 61 | YIX5MyyW7e17uB96KYPwFioCSpFQKECd929r71Mm6uitf34/FIUBoglJozuOKXDf 62 | dnzMVRqLNA8qdX1+sN5XQeyBjFp2eokiampycIyo07buU6khEemZxvGOfQsbpHD6 63 | AuBk4Dj/3oYqlXWmg2aGiUHsERbHnwcHGNP1QBMFnZtZTGYiK4dc5JS90drDCIXI 64 | c1XpnJ6f5yqQZS8eUMO6x+cUxYRqCvsPyZPTP07J3zem4i/os20S5tJXH4PctzuX 65 | YQ5JiUOVkPFG77gw1Cq/WKLppS3k7+VRcNbX9wWZb6fs/Ruo1STtPG4llFNC8DcG 66 | -----END RSA PRIVATE KEY----- 67 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | v1.4.1 (2016-01-11) 2 | =================== 3 | * APNS: Increased max device token size to 100 bytes (WWDC 2015, iOS 9) 4 | * BUGFIX: Fix an index error in the admin 5 | 6 | v1.4.0 (2015-12-13) 7 | =================== 8 | * BACKWARDS-INCOMPATIBLE: Drop support for Python<3.4 9 | * DJANGO: Support Django 1.9 10 | * GCM: Handle canonical IDs 11 | * GCM: Allow full range of GCMDevice.device_id values 12 | * GCM: Do not allow duplicate registration_ids 13 | * DRF: Work around empty boolean defaults issue (django-rest-framework#1101) 14 | * BUGFIX: Do not throw GCMError in bulk messages from the admin 15 | * BUGFIX: Avoid generating an extra migration on Python 3 16 | * BUGFIX: Only send in bulk to active devices 17 | * BUGFIX: Display models correctly in the admin on both Python 2 and 3 18 | 19 | 20 | v1.3.1 (2015-06-30) 21 | =================== 22 | This is an errata release. 23 | 24 | v1.3.0 (2015-06-30) 25 | =================== 26 | * BACKWARDS-INCOMPATIBLE: Drop support for Python<2.7 27 | * BACKWARDS-INCOMPATIBLE: Drop support for Django<1.8 28 | * NEW FEATURE: Added a Django Rest Framework API. Requires DRF>=3.0. 29 | * APNS: Add support for setting the ca_certs file with new APNS_CA_CERTIFICATES setting 30 | * GCM: Deactivate GCMDevices when their notifications cause NotRegistered or InvalidRegistration 31 | * GCM: Indiscriminately handle all keyword arguments in gcm_send_message and gcm_send_bulk_message 32 | * GCM: Never fall back to json in gcm_send_message 33 | * BUGFIX: Fixed migration issues from 1.2.0 upgrade. 34 | * BUGFIX: Better detection of SQLite/GIS MySQL in various checks 35 | * BUGFIX: Assorted Python 3 bugfixes 36 | * BUGFIX: Fix display of device_id in admin 37 | 38 | v1.2.1 (2015-04-11) 39 | =================== 40 | * APNS, GCM: Add a db_index to the device_id field 41 | * APNS: Use the native UUIDField on Django 1.8 42 | * APNS: Fix timeout handling on Python 3 43 | * APNS: Restore error checking on apns_send_bulk_message 44 | * GCM: Expose the time_to_live argument in gcm_send_bulk_message 45 | * GCM: Fix return value when gcm bulk is split in batches 46 | * GCM: Improved error checking reliability 47 | * GCM: Properly pass kwargs in GCMDeviceQuerySet.send_message() 48 | * BUGFIX: Fix HexIntegerField for Django 1.3 49 | 50 | v1.2.0 (2014-10-07) 51 | =================== 52 | * BACKWARDS-INCOMPATIBLE: Added support for Django 1.7 migrations. South users will have to upgrade to South 1.0 or Django 1.7. 53 | * APNS: APNS MAX_NOTIFICATION_SIZE is now a setting and its default has been increased to 2048 54 | * APNS: Always connect with TLSv1 instead of SSLv3 55 | * APNS: Implemented support for APNS Feedback Service 56 | * APNS: Support for optional "category" dict 57 | * GCM: Improved error handling in bulk mode 58 | * GCM: Added support for time_to_live parameter 59 | * BUGFIX: Fixed various issues relating HexIntegerField 60 | * BUGFIX: Fixed issues in the admin with custom user models 61 | 62 | v1.1.0 (2014-06-29) 63 | =================== 64 | * BACKWARDS-INCOMPATIBLE: The arguments for device.send_message() have changed. See README.rst for details. 65 | * Added a date_created field to GCMDevice and APNSDevice. This field keeps track of when the Device was created. 66 | This requires a `manage.py migrate`. 67 | * Updated APNS protocol support 68 | * Allow sending empty sounds on APNS 69 | * Several APNS bugfixes 70 | * Fixed BigIntegerField support on PostGIS 71 | * Assorted migrations bugfixes 72 | * Added a test suite 73 | 74 | v1.0.1 (2013-01-16) 75 | =================== 76 | * Migrations have been reset. If you were using migrations pre-1.0 you should upgrade to 1.0 instead and only 77 | upgrade to 1.0.1 when you are ready to reset your migrations. 78 | 79 | v1.0 (2013-01-15) 80 | ================= 81 | * Full Python 3 support 82 | * GCM device_id is now a custom field based on BigIntegerField and always unsigned (it should be input as hex) 83 | * Django versions older than 1.5 now require 'six' to be installed 84 | * Drop uniqueness on gcm registration_id due to compatibility issues with MySQL 85 | * Fix some issues with migrations 86 | * Add some basic tests 87 | * Integrate with travis-ci 88 | * Add an AUTHORS file 89 | 90 | v0.9 (2013-12-17) 91 | ================= 92 | 93 | * Enable installation with pip 94 | * Add wheel support 95 | * Add full documentation 96 | * Various bug fixes 97 | 98 | v0.8 (2013-03-15) 99 | ================= 100 | 101 | * Initial release 102 | -------------------------------------------------------------------------------- /push_notifications/fields.py: -------------------------------------------------------------------------------- 1 | import re 2 | import struct 3 | from django import forms 4 | from django.core.validators import MaxValueValidator 5 | from django.core.validators import MinValueValidator 6 | from django.core.validators import RegexValidator 7 | from django.db import models, connection 8 | from django.utils import six 9 | from django.utils.translation import ugettext_lazy as _ 10 | 11 | UNSIGNED_64BIT_INT_MIN_VALUE = 0 12 | UNSIGNED_64BIT_INT_MAX_VALUE = 2 ** 64 - 1 13 | 14 | __all__ = ["HexadecimalField", "HexIntegerField"] 15 | 16 | 17 | hex_re = re.compile(r"^(([0-9A-f])|(0x[0-9A-f]))+$") 18 | signed_integer_engines = [ 19 | "django.db.backends.postgresql_psycopg2", 20 | "django.contrib.gis.db.backends.postgis", 21 | "django.db.backends.sqlite3" 22 | ] 23 | 24 | 25 | def _using_signed_storage(): 26 | return connection.settings_dict["ENGINE"] in signed_integer_engines 27 | 28 | 29 | def _signed_to_unsigned_integer(value): 30 | return struct.unpack("Q", struct.pack("q", value))[0] 31 | 32 | 33 | def _unsigned_to_signed_integer(value): 34 | return struct.unpack("q", struct.pack("Q", value))[0] 35 | 36 | 37 | def _hex_string_to_unsigned_integer(value): 38 | return int(value, 16) 39 | 40 | 41 | def _unsigned_integer_to_hex_string(value): 42 | return hex(value).rstrip("L") 43 | 44 | 45 | class HexadecimalField(forms.CharField): 46 | """ 47 | A form field that accepts only hexadecimal numbers 48 | """ 49 | def __init__(self, *args, **kwargs): 50 | self.default_validators = [RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid")] 51 | super(HexadecimalField, self).__init__(*args, **kwargs) 52 | 53 | def prepare_value(self, value): 54 | # converts bigint from db to hex before it is displayed in admin 55 | if value and not isinstance(value, six.string_types) \ 56 | and connection.vendor in ("mysql", "sqlite"): 57 | value = _unsigned_integer_to_hex_string(value) 58 | return super(forms.CharField, self).prepare_value(value) 59 | 60 | 61 | class HexIntegerField(models.BigIntegerField): 62 | """ 63 | This field stores a hexadecimal *string* of up to 64 bits as an unsigned integer 64 | on *all* backends including postgres. 65 | 66 | Reasoning: Postgres only supports signed bigints. Since we don't care about 67 | signedness, we store it as signed, and cast it to unsigned when we deal with 68 | the actual value (with struct) 69 | 70 | On sqlite and mysql, native unsigned bigint types are used. In all cases, the 71 | value we deal with in python is always in hex. 72 | """ 73 | 74 | validators = [ 75 | MinValueValidator(UNSIGNED_64BIT_INT_MIN_VALUE), 76 | MaxValueValidator(UNSIGNED_64BIT_INT_MAX_VALUE) 77 | ] 78 | 79 | def db_type(self, connection): 80 | engine = connection.settings_dict["ENGINE"] 81 | if "mysql" in engine: 82 | return "bigint unsigned" 83 | elif "sqlite" in engine: 84 | return "UNSIGNED BIG INT" 85 | else: 86 | return super(HexIntegerField, self).db_type(connection=connection) 87 | 88 | def get_prep_value(self, value): 89 | """ Return the integer value to be stored from the hex string """ 90 | if value is None or value == "": 91 | return None 92 | if isinstance(value, six.string_types): 93 | value = _hex_string_to_unsigned_integer(value) 94 | if _using_signed_storage(): 95 | value = _unsigned_to_signed_integer(value) 96 | return value 97 | 98 | def from_db_value(self, value, expression, connection, context): 99 | """ Return an unsigned int representation from all db backends """ 100 | if value is None: 101 | return value 102 | if _using_signed_storage(): 103 | value = _signed_to_unsigned_integer(value) 104 | return value 105 | 106 | def to_python(self, value): 107 | """ Return a str representation of the hexadecimal """ 108 | if isinstance(value, six.string_types): 109 | return value 110 | if value is None: 111 | return value 112 | return _unsigned_integer_to_hex_string(value) 113 | 114 | def formfield(self, **kwargs): 115 | defaults = {"form_class": HexadecimalField} 116 | defaults.update(kwargs) 117 | # yes, that super call is right 118 | return super(models.IntegerField, self).formfield(**defaults) 119 | 120 | def run_validators(self, value): 121 | # make sure validation is performed on integer value not string value 122 | value = _hex_string_to_unsigned_integer(value) 123 | return super(models.BigIntegerField, self).run_validators(value) 124 | -------------------------------------------------------------------------------- /tests/test_data/good_revoked.pem: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | friendlyName: Apple Development IOS Push Services: com.baseride.Magnitapp 3 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 4 | subject=/UID=com.baseride.Magnitapp/CN=Apple Development IOS Push Services: com.baseride.Magnitapp/OU=QAMD48Y2CA/C=US 5 | issuer=/C=US/O=Apple Inc./OU=Apple Worldwide Developer Relations/CN=Apple Worldwide Developer Relations Certification Authority 6 | -----BEGIN CERTIFICATE----- 7 | MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 8 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 9 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 10 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 11 | HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk 12 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 13 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 14 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 15 | AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp 16 | yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV 17 | 72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg 18 | /hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif 19 | u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0 20 | EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh 21 | MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud 22 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 23 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 24 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 25 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 26 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 27 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 28 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 29 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 30 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 31 | ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7 32 | Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE 33 | 6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG 34 | hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO 35 | 0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe 36 | 7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A== 37 | -----END CERTIFICATE----- 38 | Bag Attributes 39 | friendlyName: PushNotificationCloudBus 40 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 41 | Key Attributes: 42 | -----BEGIN RSA PRIVATE KEY----- 43 | MIIEogIBAAKCAQEA0W06MJky6FWGgQ2JHV3zzGwF4oHYPFOFwCEKe2nJhIZ5DKqz 44 | MyCmQzgNYasX8GYDPLAC+JL1ji7JnpZprBLRWKpZ1EbiUvWuI4qAJNXvYfjyWoov 45 | DJG5BBNcI5IGxCBeHHFa4NzycxobkuCkk6qMcz5btPOwzvYrMNqB02D+FSp/Xq5d 46 | up18JdxHIv33Bs+wBDVOsjfATFMCakQGl6jvjYiuG8zr8ClB4qUeiJ+7j2aC5NzI 47 | fiwUs835PbOa7ZpLauyBmvKPUzOr/IoTyriXTo7bP8SVURywIU9phXQQXuc0Qbiz 48 | DWSJQMR7sdMEUWmhGLVr2wkujJOEVekkzBsgnwIDAQABAoIBACOs06jLsBxb1VnO 49 | kHjsNEeybx4yuD8uiy47cqmrT6S/s4cw3O3steXleoIUvzM4bXy9DwSBJEtgNQBK 50 | 5x1k5zyPaFX87TjsmQl84m9j8i9iVQaPW4xslnPXSG7WxUhLqzx1IuIDQVnSLLhM 51 | hDyTZPGMwdqFWK0oyhq8Xjk/4IiCMcYG2M14jGVvEIsjMF246v+inAIpSUwZr1FD 52 | qzylj1FRnm0hTjXKIWrvumDiIodybFK5ruGbaKWlciokmyBaFXlt5JCzG1hrGetf 53 | wgg6gomjqSf7WuWILjWhHr6ZeNVKm8KdyOCs0csY1DSQj+CsLjUCF8fvE+59dN2k 54 | /u+qASECgYEA9Me6OcT6EhrbD1aqmDfg+DgFp6IOkP0We8Y2Z3Yg9fSzwRz0bZmE 55 | T9juuelhUxDph74fHvLmE0iMrueMIbWvzF8xGef0JIpvMVQmxvslzqRLFfPRclbA 56 | WoSWm8pzaI/X+tZetlQySoVVeS21HbzIEKnPdFBdkyC397xyV+iCQLsCgYEA2wao 57 | llTQ9TvQYDKad6T8RsgdCnk/BwLaq6EkLI+sNOBDRlzeSYbKkfqkwIPOhORe/ibg 58 | 2OO76P8QrmqLg76hQlYK4i6k/Fwz3pRajdfQ6KxS7sOLm0x5aqrFXHVhKVnCD5C9 59 | PldJ2mOAowAEe7HMPcNeYbX9bW6T1hcslTKkI20CgYAJxkP4dJYrzOi8dxB+3ZRd 60 | NRd8tyrvvTt9m8+mWAA+8hOPfZGBIuU2rwnxYJFjWMSKiBwEB10KnhYIEfT1j6TC 61 | e3ahezKzlteT17FotrSuyL661K6jazVpJ+w/sljjbwMH4DGOBFSxxxs/qISX+Gbg 62 | y3ceROtHqcHO4baLLhytawKBgC9wosVk+5mSahDcBQ8TIj1mjLu/BULMgHaaQY6R 63 | U/hj9s5fwRnl4yx5QIQeSHYKTPT5kMwJj6Lo1EEi/LL9cEpA/rx84+lxQx7bvT1p 64 | 2Gr9ID1tB2kMyGOtN3BOUEw3j8v1SrgdCfcOhEdJ8q6kFRvvnBrH42t3fvfpLxPl 65 | 0x2FAoGAbSkII3zPpc8mRcD3rtyOl2TlahBtXMuxfWSxsg/Zwf47crBfEbLD+5wz 66 | 7A9qnfwiDO98GJyE5/s+ynnL2PhIomCm0P13nkZuC4d9twYMtEvcD20mdQ+gsEhz 67 | Eg8ssRvYkO8DQwAFJKJVwVtVqMcnm/fkWu8GIfgqH6/fWNev6vs= 68 | -----END RSA PRIVATE KEY----- 69 | -------------------------------------------------------------------------------- /tests/test_rest_framework.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from push_notifications.api.rest_framework import APNSDeviceSerializer, GCMDeviceSerializer 3 | from rest_framework.serializers import ValidationError 4 | from tests.mock_responses import GCM_DRF_INVALID_HEX_ERROR, GCM_DRF_OUT_OF_RANGE_ERROR 5 | 6 | 7 | class APNSDeviceSerializerTestCase(TestCase): 8 | def test_validation(self): 9 | # valid data - 32 bytes upper case 10 | serializer = APNSDeviceSerializer(data={ 11 | "registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE", 12 | "name": "Apple iPhone 6+", 13 | "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 14 | }) 15 | self.assertTrue(serializer.is_valid()) 16 | 17 | # valid data - 32 bytes lower case 18 | serializer = APNSDeviceSerializer(data={ 19 | "registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", 20 | "name": "Apple iPhone 6+", 21 | "device_id": "ffffffffffffffffffffffffffffffff", 22 | }) 23 | self.assertTrue(serializer.is_valid()) 24 | 25 | # valid data - 100 bytes upper case 26 | serializer = APNSDeviceSerializer(data={ 27 | "registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE", 28 | "name": "Apple iPhone 6+", 29 | "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 30 | }) 31 | self.assertTrue(serializer.is_valid()) 32 | 33 | # valid data - 100 bytes lower case 34 | serializer = APNSDeviceSerializer(data={ 35 | "registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", 36 | "name": "Apple iPhone 6+", 37 | "device_id": "ffffffffffffffffffffffffffffffff", 38 | }) 39 | self.assertTrue(serializer.is_valid()) 40 | 41 | # invalid data - device_id, registration_id 42 | serializer = APNSDeviceSerializer(data={ 43 | "registration_id": "invalid device token contains no hex", 44 | "name": "Apple iPhone 6+", 45 | "device_id": "ffffffffffffffffffffffffffffake", 46 | }) 47 | self.assertFalse(serializer.is_valid()) 48 | self.assertEqual(serializer.errors["device_id"][0], '"ffffffffffffffffffffffffffffake" is not a valid UUID.') 49 | self.assertEqual(serializer.errors["registration_id"][0], "Registration ID (device token) is invalid") 50 | 51 | 52 | class GCMDeviceSerializerTestCase(TestCase): 53 | def test_device_id_validation_pass(self): 54 | serializer = GCMDeviceSerializer(data={ 55 | "registration_id": "foobar", 56 | "name": "Galaxy Note 3", 57 | "device_id": "0x1031af3b", 58 | }) 59 | self.assertTrue(serializer.is_valid()) 60 | 61 | def test_registration_id_unique(self): 62 | """Validate that a duplicate registration id raises a validation error.""" 63 | 64 | # add a device 65 | serializer = GCMDeviceSerializer(data={ 66 | "registration_id": "foobar", 67 | "name": "Galaxy Note 3", 68 | "device_id": "0x1031af3b", 69 | }) 70 | serializer.is_valid(raise_exception=True) 71 | obj = serializer.save() 72 | 73 | # ensure updating the same object works 74 | serializer = GCMDeviceSerializer(obj, data={ 75 | "registration_id": "foobar", 76 | "name": "Galaxy Note 5", 77 | "device_id": "0x1031af3b", 78 | }) 79 | serializer.is_valid(raise_exception=True) 80 | obj = serializer.save() 81 | 82 | # try to add a new device with the same token 83 | serializer = GCMDeviceSerializer(data={ 84 | "registration_id": "foobar", 85 | "name": "Galaxy Note 3", 86 | "device_id": "0xdeadbeaf", 87 | }) 88 | 89 | with self.assertRaises(ValidationError) as ex: 90 | serializer.is_valid(raise_exception=True) 91 | self.assertEqual({'registration_id': [u'This field must be unique.']}, ex.exception.detail) 92 | 93 | def test_device_id_validation_fail_bad_hex(self): 94 | serializer = GCMDeviceSerializer(data={ 95 | "registration_id": "foobar", 96 | "name": "Galaxy Note 3", 97 | "device_id": "0x10r", 98 | }) 99 | self.assertFalse(serializer.is_valid()) 100 | self.assertEqual(serializer.errors, GCM_DRF_INVALID_HEX_ERROR) 101 | 102 | def test_device_id_validation_fail_out_of_range(self): 103 | serializer = GCMDeviceSerializer(data={ 104 | "registration_id": "foobar", 105 | "name": "Galaxy Note 3", 106 | "device_id": "10000000000000000", # 2**64 107 | }) 108 | self.assertFalse(serializer.is_valid()) 109 | self.assertEqual(serializer.errors, GCM_DRF_OUT_OF_RANGE_ERROR) 110 | 111 | def test_device_id_validation_value_between_signed_unsigned_64b_int_maximums(self): 112 | """ 113 | 2**63 < 0xe87a4e72d634997c < 2**64 114 | """ 115 | serializer = GCMDeviceSerializer(data={ 116 | "registration_id": "foobar", 117 | "name": "Nexus 5", 118 | "device_id": "e87a4e72d634997c", 119 | }) 120 | self.assertTrue(serializer.is_valid()) 121 | -------------------------------------------------------------------------------- /push_notifications/api/rest_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from rest_framework import permissions 4 | from rest_framework.serializers import ModelSerializer, ValidationError 5 | from rest_framework.viewsets import ModelViewSet 6 | from rest_framework.fields import IntegerField 7 | 8 | from push_notifications.models import APNSDevice, GCMDevice 9 | from push_notifications.fields import hex_re 10 | from push_notifications.fields import UNSIGNED_64BIT_INT_MAX_VALUE 11 | 12 | # Fields 13 | 14 | 15 | class HexIntegerField(IntegerField): 16 | """ 17 | Store an integer represented as a hex string of form "0x01". 18 | """ 19 | 20 | def to_internal_value(self, data): 21 | # validate hex string and convert it to the unsigned 22 | # integer representation for internal use 23 | try: 24 | data = int(data, 16) if type(data) != int else data 25 | except ValueError: 26 | raise ValidationError("Device ID is not a valid hex number") 27 | return super(HexIntegerField, self).to_internal_value(data) 28 | 29 | def to_representation(self, value): 30 | return value 31 | 32 | 33 | # Serializers 34 | class DeviceSerializerMixin(ModelSerializer): 35 | class Meta: 36 | fields = ("id", "name", "registration_id", "device_id", "active", "date_created") 37 | read_only_fields = ("date_created", ) 38 | 39 | # See https://github.com/tomchristie/django-rest-framework/issues/1101 40 | extra_kwargs = {"active": {"default": True}} 41 | 42 | 43 | class APNSDeviceSerializer(ModelSerializer): 44 | 45 | class Meta(DeviceSerializerMixin.Meta): 46 | model = APNSDevice 47 | 48 | def validate_registration_id(self, value): 49 | # iOS device tokens are 256-bit hexadecimal (64 characters). In 2016 Apple is increasing 50 | # iOS device tokens to 100 bytes hexadecimal (200 characters). 51 | 52 | if hex_re.match(value) is None or len(value) not in (64, 200): 53 | raise ValidationError("Registration ID (device token) is invalid") 54 | 55 | return value 56 | 57 | 58 | class GCMDeviceSerializer(ModelSerializer): 59 | device_id = HexIntegerField( 60 | help_text="ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)", 61 | style={"input_type": "text"}, 62 | required=False, 63 | allow_null=True 64 | ) 65 | 66 | class Meta(DeviceSerializerMixin.Meta): 67 | model = GCMDevice 68 | 69 | extra_kwargs = {"id": {"read_only": False, "required": False}} 70 | 71 | def validate_device_id(self, value): 72 | # device ids are 64 bit unsigned values 73 | if value > UNSIGNED_64BIT_INT_MAX_VALUE: 74 | raise ValidationError("Device ID is out of range") 75 | return value 76 | 77 | def validate(self, attrs): 78 | devices = None 79 | primary_key = None 80 | request_method = None 81 | 82 | if self.initial_data.get("registration_id", None): 83 | if self.instance: 84 | request_method = "update" 85 | primary_key = self.instance.id 86 | else: 87 | request_method = "create" 88 | else: 89 | if self.context["request"].method in ["PUT", "PATCH"]: 90 | request_method = "update" 91 | primary_key = attrs["id"] 92 | elif self.context["request"].method == "POST": 93 | request_method = "create" 94 | 95 | if request_method == "update": 96 | devices = GCMDevice.objects.filter(registration_id=attrs["registration_id"]) \ 97 | .exclude(id=primary_key) 98 | elif request_method == "create": 99 | devices = GCMDevice.objects.filter(registration_id=attrs["registration_id"]) 100 | 101 | if devices: 102 | raise ValidationError({'registration_id': 'This field must be unique.'}) 103 | return attrs 104 | 105 | 106 | # Permissions 107 | class IsOwner(permissions.BasePermission): 108 | def has_object_permission(self, request, view, obj): 109 | # must be the owner to view the object 110 | return obj.user == request.user 111 | 112 | 113 | # Mixins 114 | class DeviceViewSetMixin(object): 115 | lookup_field = "registration_id" 116 | 117 | def perform_create(self, serializer): 118 | if self.request.user.is_authenticated(): 119 | serializer.save(user=self.request.user) 120 | return super(DeviceViewSetMixin, self).perform_create(serializer) 121 | 122 | def perform_update(self, serializer): 123 | if self.request.user.is_authenticated(): 124 | serializer.save(user=self.request.user) 125 | return super(DeviceViewSetMixin, self).perform_update(serializer) 126 | 127 | 128 | class AuthorizedMixin(object): 129 | permission_classes = (permissions.IsAuthenticated, IsOwner) 130 | 131 | def get_queryset(self): 132 | # filter all devices to only those belonging to the current user 133 | return self.queryset.filter(user=self.request.user) 134 | 135 | 136 | # ViewSets 137 | class APNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 138 | queryset = APNSDevice.objects.all() 139 | serializer_class = APNSDeviceSerializer 140 | 141 | 142 | class APNSDeviceAuthorizedViewSet(AuthorizedMixin, APNSDeviceViewSet): 143 | pass 144 | 145 | 146 | class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 147 | queryset = GCMDevice.objects.all() 148 | serializer_class = GCMDeviceSerializer 149 | 150 | 151 | class GCMDeviceAuthorizedViewSet(AuthorizedMixin, GCMDeviceViewSet): 152 | pass 153 | -------------------------------------------------------------------------------- /push_notifications/gcm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Google Cloud Messaging 3 | Previously known as C2DM 4 | Documentation is available on the Android Developer website: 5 | https://developer.android.com/google/gcm/index.html 6 | """ 7 | 8 | import json 9 | from .models import GCMDevice 10 | 11 | 12 | try: 13 | from urllib.request import Request, urlopen 14 | from urllib.parse import urlencode 15 | except ImportError: 16 | # Python 2 support 17 | from urllib2 import Request, urlopen 18 | from urllib import urlencode 19 | 20 | from django.core.exceptions import ImproperlyConfigured 21 | from . import NotificationError 22 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 23 | 24 | 25 | class GCMError(NotificationError): 26 | pass 27 | 28 | 29 | def _chunks(l, n): 30 | """ 31 | Yield successive chunks from list \a l with a minimum size \a n 32 | """ 33 | for i in range(0, len(l), n): 34 | yield l[i:i + n] 35 | 36 | 37 | def _gcm_send(data, content_type): 38 | key = SETTINGS.get("GCM_API_KEY") 39 | if not key: 40 | raise ImproperlyConfigured('You need to set PUSH_NOTIFICATIONS_SETTINGS["GCM_API_KEY"] to send messages through GCM.') 41 | 42 | headers = { 43 | "Content-Type": content_type, 44 | "Authorization": "key=%s" % (key), 45 | "Content-Length": str(len(data)), 46 | } 47 | 48 | request = Request(SETTINGS["GCM_POST_URL"], data, headers) 49 | return urlopen(request).read().decode("utf-8") 50 | 51 | 52 | def _gcm_send_plain(registration_id, data, **kwargs): 53 | """ 54 | Sends a GCM notification to a single registration_id or to a topic (If "topic" included in the kwargs). 55 | This will send the notification as form data. 56 | If sending multiple notifications, it is more efficient to use 57 | gcm_send_bulk_message() with a list of registration_ids 58 | """ 59 | 60 | values = {"registration_id": registration_id} if registration_id else {} 61 | 62 | for k, v in data.items(): 63 | values["data.%s" % (k)] = v.encode("utf-8") 64 | 65 | for k, v in kwargs.items(): 66 | if v: 67 | if isinstance(v, bool): 68 | # Encode bools into ints 69 | v = 1 70 | values[k] = v 71 | 72 | data = urlencode(sorted(values.items())).encode("utf-8") # sorted items for tests 73 | 74 | result = _gcm_send(data, "application/x-www-form-urlencoded;charset=UTF-8") 75 | 76 | # Information about handling response from Google docs (https://developers.google.com/cloud-messaging/http): 77 | # If first line starts with id, check second line: 78 | # If second line starts with registration_id, gets its value and replace the registration tokens in your 79 | # server database. Otherwise, get the value of Error 80 | 81 | if result.startswith("id"): 82 | lines = result.split("\n") 83 | if len(lines) > 1 and lines[1].startswith("registration_id"): 84 | new_id = lines[1].split("=")[-1] 85 | _gcm_handle_canonical_id(new_id, registration_id) 86 | 87 | elif result.startswith("Error="): 88 | if result in ("Error=NotRegistered", "Error=InvalidRegistration"): 89 | # Deactivate the problematic device 90 | device = GCMDevice.objects.filter(registration_id=values["registration_id"]) 91 | device.update(active=0) 92 | return result 93 | 94 | raise GCMError(result) 95 | 96 | return result 97 | 98 | 99 | def _gcm_send_json(registration_ids, data, **kwargs): 100 | """ 101 | Sends a GCM notification to one or more registration_ids. The registration_ids 102 | needs to be a list. 103 | This will send the notification as json data. 104 | """ 105 | 106 | values = {"registration_ids": registration_ids} 107 | 108 | if data is not None: 109 | values["data"] = data 110 | 111 | for k, v in kwargs.items(): 112 | if v: 113 | values[k] = v 114 | 115 | data = json.dumps(values, separators=(",", ":"), sort_keys=True).encode("utf-8") # keys sorted for tests 116 | 117 | response = json.loads(_gcm_send(data, "application/json")) 118 | if response["failure"] or response["canonical_ids"]: 119 | ids_to_remove, old_new_ids = [], [] 120 | throw_error = False 121 | for index, result in enumerate(response["results"]): 122 | error = result.get("error") 123 | if error: 124 | # Information from Google docs (https://developers.google.com/cloud-messaging/http) 125 | # If error is NotRegistered or InvalidRegistration, then we will deactivate devices because this 126 | # registration ID is no more valid and can't be used to send messages, otherwise raise error 127 | if error in ("NotRegistered", "InvalidRegistration"): 128 | ids_to_remove.append(registration_ids[index]) 129 | else: 130 | throw_error = True 131 | 132 | # If registration_id is set, replace the original ID with the new value (canonical ID) in your 133 | # server database. Note that the original ID is not part of the result, so you need to obtain it 134 | # from the list of registration_ids passed in the request (using the same index). 135 | new_id = result.get("registration_id") 136 | if new_id: 137 | old_new_ids.append((registration_ids[index], new_id)) 138 | 139 | if ids_to_remove: 140 | removed = GCMDevice.objects.filter(registration_id__in=ids_to_remove) 141 | removed.update(active=0) 142 | 143 | for old_id, new_id in old_new_ids: 144 | _gcm_handle_canonical_id(new_id, old_id) 145 | 146 | if throw_error: 147 | raise GCMError(response) 148 | return response 149 | 150 | 151 | def _gcm_handle_canonical_id(canonical_id, current_id): 152 | """ 153 | Handle situation when GCM server response contains canonical ID 154 | """ 155 | if GCMDevice.objects.filter(registration_id=canonical_id, active=True).exists(): 156 | GCMDevice.objects.filter(registration_id=current_id).update(active=False) 157 | else: 158 | GCMDevice.objects.filter(registration_id=current_id).update(registration_id=canonical_id) 159 | 160 | 161 | def gcm_send_message(registration_id, data, **kwargs): 162 | """ 163 | Sends a GCM notification to a single registration_id. 164 | 165 | If sending multiple notifications, it is more efficient to use 166 | gcm_send_bulk_message() with a list of registration_ids 167 | 168 | A reference of extra keyword arguments sent to the server is available here: 169 | https://developers.google.com/cloud-messaging/server-ref#downstream 170 | """ 171 | 172 | if registration_id: 173 | return _gcm_send_plain(registration_id, data, **kwargs) 174 | 175 | 176 | def gcm_send_bulk_message(registration_ids, data, **kwargs): 177 | """ 178 | Sends a GCM notification to one or more registration_ids. The registration_ids 179 | needs to be a list. 180 | This will send the notification as json data. 181 | 182 | A reference of extra keyword arguments sent to the server is available here: 183 | https://developers.google.com/cloud-messaging/server-ref#downstream 184 | """ 185 | 186 | # GCM only allows up to 1000 reg ids per bulk message 187 | # https://developer.android.com/google/gcm/gcm.html#request 188 | if registration_ids: 189 | max_recipients = SETTINGS.get("GCM_MAX_RECIPIENTS") 190 | if len(registration_ids) > max_recipients: 191 | ret = [] 192 | for chunk in _chunks(registration_ids, max_recipients): 193 | ret.append(_gcm_send_json(chunk, data, **kwargs)) 194 | return ret 195 | 196 | return _gcm_send_json(registration_ids, data, **kwargs) 197 | -------------------------------------------------------------------------------- /push_notifications/apns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apple Push Notification Service 3 | Documentation is available on the iOS Developer Library: 4 | https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html 5 | """ 6 | 7 | import codecs 8 | import json 9 | import ssl 10 | import struct 11 | import socket 12 | import time 13 | from contextlib import closing 14 | from binascii import unhexlify 15 | from django.core.exceptions import ImproperlyConfigured 16 | from . import NotificationError 17 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 18 | 19 | 20 | class APNSError(NotificationError): 21 | pass 22 | 23 | 24 | class APNSServerError(APNSError): 25 | def __init__(self, status, identifier): 26 | super(APNSServerError, self).__init__(status, identifier) 27 | self.status = status 28 | self.identifier = identifier 29 | 30 | 31 | class APNSDataOverflow(APNSError): 32 | pass 33 | 34 | 35 | def _check_certificate(ss): 36 | mode = 'start' 37 | for s in ss.split('\n'): 38 | if mode == 'start': 39 | if 'BEGIN RSA PRIVATE KEY' in s: 40 | mode = 'key' 41 | elif mode == 'key': 42 | if 'END RSA PRIVATE KEY' in s: 43 | mode = 'end' 44 | break 45 | elif s.startswith('Proc-Type') and 'ENCRYPTED' in s: 46 | raise Exception("The certificate private key should not be encrypted") 47 | if mode != 'end': 48 | raise Exception("The certificate doesn't contain a private key") 49 | 50 | 51 | def _apns_create_socket(address_tuple): 52 | certfile = SETTINGS.get("APNS_CERTIFICATE") 53 | if not certfile: 54 | raise ImproperlyConfigured( 55 | 'You need to set PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] to send messages through APNS.' 56 | ) 57 | 58 | try: 59 | with open(certfile, "r") as f: 60 | content = f.read() 61 | except Exception as e: 62 | raise ImproperlyConfigured("The APNS certificate file at %r is not readable: %s" % (certfile, e)) 63 | 64 | try: 65 | _check_certificate(content) 66 | except Exception as e: 67 | raise ImproperlyConfigured("The APNS certificate file at %r is unusable: %s" % (certfile, e)) 68 | 69 | ca_certs = SETTINGS.get("APNS_CA_CERTIFICATES") 70 | 71 | sock = socket.socket() 72 | sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, certfile=certfile, ca_certs=ca_certs) 73 | sock.connect(address_tuple) 74 | 75 | return sock 76 | 77 | 78 | def _apns_create_socket_to_push(): 79 | return _apns_create_socket((SETTINGS["APNS_HOST"], SETTINGS["APNS_PORT"])) 80 | 81 | 82 | def _apns_create_socket_to_feedback(): 83 | return _apns_create_socket((SETTINGS["APNS_FEEDBACK_HOST"], SETTINGS["APNS_FEEDBACK_PORT"])) 84 | 85 | 86 | def _apns_pack_frame(token_hex, payload, identifier, expiration, priority): 87 | token = unhexlify(token_hex) 88 | # |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expiration:4}|{priority:1} 89 | frame_len = 3 * 5 + len(token) + len(payload) + 4 + 4 + 1 # 5 items, each 3 bytes prefix, then each item length 90 | frame_fmt = "!BIBH%ssBH%ssBHIBHIBHB" % (len(token), len(payload)) 91 | frame = struct.pack( 92 | frame_fmt, 93 | 2, frame_len, 94 | 1, len(token), token, 95 | 2, len(payload), payload, 96 | 3, 4, identifier, 97 | 4, 4, expiration, 98 | 5, 1, priority) 99 | 100 | return frame 101 | 102 | 103 | def _apns_check_errors(sock): 104 | timeout = SETTINGS["APNS_ERROR_TIMEOUT"] 105 | if timeout is None: 106 | return # assume everything went fine! 107 | saved_timeout = sock.gettimeout() 108 | try: 109 | sock.settimeout(timeout) 110 | data = sock.recv(6) 111 | if data: 112 | command, status, identifier = struct.unpack("!BBI", data) 113 | # apple protocol says command is always 8. See http://goo.gl/ENUjXg 114 | assert command == 8, "Command must be 8!" 115 | if status != 0: 116 | raise APNSServerError(status, identifier) 117 | except socket.timeout: # py3, see http://bugs.python.org/issue10272 118 | pass 119 | except ssl.SSLError as e: # py2 120 | if "timed out" not in e.message: 121 | raise 122 | finally: 123 | sock.settimeout(saved_timeout) 124 | 125 | 126 | def _apns_send(token, alert, badge=None, sound=None, category=None, content_available=False, 127 | action_loc_key=None, loc_key=None, loc_args=[], extra={}, identifier=0, 128 | expiration=None, priority=10, socket=None): 129 | data = {} 130 | aps_data = {} 131 | 132 | if action_loc_key or loc_key or loc_args: 133 | alert = {"body": alert} if alert else {} 134 | if action_loc_key: 135 | alert["action-loc-key"] = action_loc_key 136 | if loc_key: 137 | alert["loc-key"] = loc_key 138 | if loc_args: 139 | alert["loc-args"] = loc_args 140 | 141 | if alert is not None: 142 | aps_data["alert"] = alert 143 | 144 | if badge is not None: 145 | aps_data["badge"] = badge 146 | 147 | if sound is not None: 148 | aps_data["sound"] = sound 149 | 150 | if category is not None: 151 | aps_data["category"] = category 152 | 153 | if content_available: 154 | aps_data["content-available"] = 1 155 | 156 | data["aps"] = aps_data 157 | data.update(extra) 158 | 159 | # convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests) 160 | json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") 161 | 162 | max_size = SETTINGS["APNS_MAX_NOTIFICATION_SIZE"] 163 | if len(json_data) > max_size: 164 | raise APNSDataOverflow("Notification body cannot exceed %i bytes" % (max_size)) 165 | 166 | # if expiration isn't specified use 1 month from now 167 | expiration_time = expiration if expiration is not None else int(time.time()) + 2592000 168 | 169 | frame = _apns_pack_frame(token, json_data, identifier, expiration_time, priority) 170 | 171 | if socket: 172 | socket.write(frame) 173 | else: 174 | with closing(_apns_create_socket_to_push()) as socket: 175 | socket.write(frame) 176 | _apns_check_errors(socket) 177 | 178 | 179 | def _apns_read_and_unpack(socket, data_format): 180 | length = struct.calcsize(data_format) 181 | data = socket.recv(length) 182 | if data: 183 | return struct.unpack_from(data_format, data, 0) 184 | else: 185 | return None 186 | 187 | 188 | def _apns_receive_feedback(socket): 189 | expired_token_list = [] 190 | 191 | # read a timestamp (4 bytes) and device token length (2 bytes) 192 | header_format = '!LH' 193 | has_data = True 194 | while has_data: 195 | try: 196 | # read the header tuple 197 | header_data = _apns_read_and_unpack(socket, header_format) 198 | if header_data is not None: 199 | timestamp, token_length = header_data 200 | # Unpack format for a single value of length bytes 201 | token_format = '%ss' % token_length 202 | device_token = _apns_read_and_unpack(socket, token_format) 203 | if device_token is not None: 204 | # _apns_read_and_unpack returns a tuple, but 205 | # it's just one item, so get the first. 206 | expired_token_list.append((timestamp, device_token[0])) 207 | else: 208 | has_data = False 209 | except socket.timeout: # py3, see http://bugs.python.org/issue10272 210 | pass 211 | except ssl.SSLError as e: # py2 212 | if "timed out" not in e.message: 213 | raise 214 | 215 | return expired_token_list 216 | 217 | 218 | def apns_send_message(registration_id, alert, **kwargs): 219 | """ 220 | Sends an APNS notification to a single registration_id. 221 | This will send the notification as form data. 222 | If sending multiple notifications, it is more efficient to use 223 | apns_send_bulk_message() 224 | 225 | Note that if set alert should always be a string. If it is not set, 226 | it won't be included in the notification. You will need to pass None 227 | to this for silent notifications. 228 | """ 229 | 230 | _apns_send(registration_id, alert, **kwargs) 231 | 232 | 233 | def apns_send_bulk_message(registration_ids, alert, **kwargs): 234 | """ 235 | Sends an APNS notification to one or more registration_ids. 236 | The registration_ids argument needs to be a list. 237 | 238 | Note that if set alert should always be a string. If it is not set, 239 | it won't be included in the notification. You will need to pass None 240 | to this for silent notifications. 241 | """ 242 | with closing(_apns_create_socket_to_push()) as socket: 243 | for identifier, registration_id in enumerate(registration_ids): 244 | _apns_send(registration_id, alert, identifier=identifier, socket=socket, **kwargs) 245 | _apns_check_errors(socket) 246 | 247 | 248 | def apns_fetch_inactive_ids(): 249 | """ 250 | Queries the APNS server for id's that are no longer active since 251 | the last fetch 252 | """ 253 | with closing(_apns_create_socket_to_feedback()) as socket: 254 | inactive_ids = [] 255 | # Maybe we should have a flag to return the timestamp? 256 | # It doesn't seem that useful right now, though. 257 | for tStamp, registration_id in _apns_receive_feedback(socket): 258 | inactive_ids.append(codecs.encode(registration_id, 'hex_codec')) 259 | return inactive_ids 260 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-push-notifications 2 | ========================= 3 | .. image:: https://api.travis-ci.org/jleclanche/django-push-notifications.png 4 | :target: https://travis-ci.org/jleclanche/django-push-notifications 5 | 6 | A minimal Django app that implements Device models that can send messages through APNS and GCM. 7 | 8 | The app implements two models: ``GCMDevice`` and ``APNSDevice``. Those models share the same attributes: 9 | - ``name`` (optional): A name for the device. 10 | - ``active`` (default True): A boolean that determines whether the device will be sent notifications. 11 | - ``user`` (optional): A foreign key to auth.User, if you wish to link the device to a specific user. 12 | - ``device_id`` (optional): A UUID for the device obtained from Android/iOS APIs, if you wish to uniquely identify it. 13 | - ``registration_id`` (required): The GCM registration id or the APNS token for the device. 14 | 15 | 16 | The app also implements an admin panel, through which you can test single and bulk notifications. Select one or more 17 | GCM or APNS devices and in the action dropdown, select "Send test message" or "Send test message in bulk", accordingly. 18 | Note that sending a non-bulk test message to more than one device will just iterate over the devices and send multiple 19 | single messages. 20 | 21 | Dependencies 22 | ------------ 23 | Django 1.8 is required. Support for older versions is available in the release 1.2.1. 24 | 25 | Tastypie support should work on Tastypie 0.11.0 and newer. 26 | 27 | Django REST Framework support should work on DRF version 3.0 and newer. 28 | 29 | Setup 30 | ----- 31 | You can install the library directly from pypi using pip: 32 | 33 | .. code-block:: shell 34 | 35 | $ pip install django-push-notifications 36 | 37 | 38 | Edit your settings.py file: 39 | 40 | .. code-block:: python 41 | 42 | INSTALLED_APPS = ( 43 | ... 44 | "push_notifications" 45 | ) 46 | 47 | PUSH_NOTIFICATIONS_SETTINGS = { 48 | "GCM_API_KEY": "[your api key]", 49 | "APNS_CERTIFICATE": "/path/to/your/certificate.pem", 50 | } 51 | 52 | .. note:: 53 | If you are planning on running your project with ``DEBUG=True``, then make sure you have set the 54 | *development* certificate as your ``APNS_CERTIFICATE``. Otherwise the app will not be able to connect to the correct host. See settings_ for details. 55 | 56 | You can learn more about APNS certificates `here `_. 57 | 58 | Native Django migrations are in use. ``manage.py migrate`` will install and migrate all models. 59 | 60 | .. _settings: 61 | 62 | Settings list 63 | ------------- 64 | All settings are contained in a ``PUSH_NOTIFICATIONS_SETTINGS`` dict. 65 | 66 | In order to use GCM, you are required to include ``GCM_API_KEY``. 67 | For APNS, you are required to include ``APNS_CERTIFICATE``. 68 | 69 | - ``APNS_CERTIFICATE``: Absolute path to your APNS certificate file. Certificates with passphrases are not supported. 70 | - ``APNS_CA_CERTIFICATES``: Absolute path to a CA certificates file for APNS. Optional - do not set if not needed. Defaults to None. 71 | - ``GCM_API_KEY``: Your API key for GCM. 72 | - ``APNS_HOST``: The hostname used for the APNS sockets. 73 | - When ``DEBUG=True``, this defaults to ``gateway.sandbox.push.apple.com``. 74 | - When ``DEBUG=False``, this defaults to ``gateway.push.apple.com``. 75 | - ``APNS_PORT``: The port used along with APNS_HOST. Defaults to 2195. 76 | - ``GCM_POST_URL``: The full url that GCM notifications will be POSTed to. Defaults to https://android.googleapis.com/gcm/send. 77 | - ``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). 78 | - ``USER_MODEL``: Your user model of choice. Eg. ``myapp.User``. Defaults to ``settings.AUTH_USER_MODEL``. 79 | 80 | Sending messages 81 | ---------------- 82 | GCM and APNS services have slightly different semantics. The app tries to offer a common interface for both when using the models. 83 | 84 | .. code-block:: python 85 | 86 | from push_notifications.models import APNSDevice, GCMDevice 87 | 88 | device = GCMDevice.objects.get(registration_id=gcm_reg_id) 89 | # The first argument will be sent as "message" to the intent extras Bundle 90 | # Retrieve it with intent.getExtras().getString("message") 91 | device.send_message("You've got mail") 92 | # If you want to customize, send an extra dict and a None message. 93 | # the extras dict will be mapped into the intent extras Bundle. 94 | # For dicts where all values are keys this will be sent as url parameters, 95 | # but for more complex nested collections the extras dict will be sent via 96 | # the bulk message api. 97 | device.send_message(None, extra={"foo": "bar"}) 98 | 99 | device = APNSDevice.objects.get(registration_id=apns_token) 100 | device.send_message("You've got mail") # Alert message may only be sent as text. 101 | device.send_message(None, badge=5) # No alerts but with badge. 102 | device.send_message(None, badge=1, extra={"foo": "bar"}) # Silent message with badge and added custom data. 103 | 104 | .. note:: 105 | APNS does not support sending payloads that exceed 2048 bytes (increased from 256 in 2014). 106 | The message is only one part of the payload, if 107 | once constructed the payload exceeds the maximum size, an ``APNSDataOverflow`` exception will be raised before anything is sent. 108 | 109 | Sending messages in bulk 110 | ------------------------ 111 | .. code-block:: python 112 | 113 | from push_notifications.models import APNSDevice, GCMDevice 114 | 115 | devices = GCMDevice.objects.filter(user__first_name="James") 116 | devices.send_message("Happy name day!") 117 | 118 | Sending messages in bulk makes use of the bulk mechanics offered by GCM and APNS. It is almost always preferable to send 119 | bulk notifications instead of single ones. 120 | 121 | 122 | Sending messages to topic members 123 | --------------------------------- 124 | 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-_.~%]+". 125 | 126 | .. code-block:: python 127 | 128 | from push_notifications.gcm import gcm_send_message 129 | 130 | # First param is "None" because no Registration_id is needed, the message will be sent to all devices subscribed to the topic. 131 | gcm_send_message(None, "Hello members of my_topic!", topic='/topics/my_topic') 132 | 133 | Reference: `GCM Documentation `_ 134 | 135 | Administration 136 | -------------- 137 | 138 | APNS devices which are not receiving push notifications can be set to inactive by two methods. The web admin interface for 139 | APNS devices has a "prune devices" option. Any selected devices which are not receiving notifications will be set to inactive [1]_. 140 | There is also a management command to prune all devices failing to receive notifications: 141 | 142 | .. code-block:: shell 143 | 144 | $ python manage.py prune_devices 145 | 146 | This removes all devices which are not receiving notifications. 147 | 148 | For more information, please refer to the APNS feedback service_. 149 | 150 | .. _service: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html 151 | 152 | Exceptions 153 | ---------- 154 | 155 | - ``NotificationError(Exception)``: Base exception for all notification-related errors. 156 | - ``gcm.GCMError(NotificationError)``: An error was returned by GCM. This is never raised when using bulk notifications. 157 | - ``apns.APNSError(NotificationError)``: Something went wrong upon sending APNS notifications. 158 | - ``apns.APNSDataOverflow(APNSError)``: The APNS payload exceeds its maximum size and cannot be sent. 159 | 160 | Tastypie support 161 | ---------------- 162 | 163 | The app includes tastypie-compatible resources in push_notifications.api.tastypie. These can be used as-is, or as base classes 164 | for more involved APIs. 165 | The following resources are available: 166 | 167 | - ``APNSDeviceResource`` 168 | - ``GCMDeviceResource`` 169 | - ``APNSDeviceAuthenticatedResource`` 170 | - ``GCMDeviceAuthenticatedResource`` 171 | 172 | The base device resources will not ask for authentication, while the authenticated ones will link the logged in user to 173 | the device they register. 174 | Subclassing the authenticated resources in order to add a ``SameUserAuthentication`` and a user ``ForeignKey`` is recommended. 175 | 176 | When registered, the APIs will show up at ``/device/apns`` and ``/device/gcm``, respectively. 177 | 178 | Django REST Framework (DRF) support 179 | ----------------------------------- 180 | 181 | ViewSets are available for both APNS and GCM devices in two permission flavors: 182 | 183 | - ``APNSDeviceViewSet`` and ``GCMDeviceViewSet`` 184 | 185 | - Permissions as specified in settings (``AllowAny`` by default, which is not recommended) 186 | - A device may be registered without associating it with a user 187 | 188 | - ``APNSDeviceAuthorizedViewSet`` and ``GCMDeviceAuthorizedViewSet`` 189 | 190 | - Permissions are ``IsAuthenticated`` and custom permission ``IsOwner``, which will only allow the ``request.user`` to get and update devices that belong to that user 191 | - Requires a user to be authenticated, so all devices will be associated with a user 192 | 193 | When creating an ``APNSDevice``, the ``registration_id`` is validated to be a 64-character or 200-character hexadecimal string. Since 2016, device tokens are to be increased from 32 bytes to 100 bytes. 194 | 195 | Routes can be added one of two ways: 196 | 197 | - Routers_ (include all views) 198 | .. _Routers: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#using-routers 199 | 200 | :: 201 | 202 | from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet 203 | from rest_framework.routers import DefaultRouter 204 | 205 | router = DefaultRouter() 206 | router.register(r'device/apns', APNSDeviceAuthorizedViewSet) 207 | router.register(r'device/gcm', GCMDeviceAuthorizedViewSet) 208 | 209 | urlpatterns = patterns('', 210 | # URLs will show up at /device/apns 211 | url(r'^', include(router.urls)), 212 | # ... 213 | ) 214 | 215 | - Using as_view_ (specify which views to include) 216 | .. _as_view: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#binding-viewsets-to-urls-explicitly 217 | 218 | :: 219 | 220 | from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet 221 | 222 | urlpatterns = patterns('', 223 | # Only allow creation of devices by authenticated users 224 | url(r'^device/apns/?$', APNSDeviceAuthorizedViewSet.as_view({'post': 'create'}), name='create_apns_device'), 225 | # ... 226 | ) 227 | 228 | 229 | Python 3 support 230 | ---------------- 231 | 232 | ``django-push-notifications`` is fully compatible with Python 3.4 & 3.5 233 | 234 | .. [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. 235 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import json 2 | import mock 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | from push_notifications.models import GCMDevice, APNSDevice 6 | from tests.mock_responses import ( GCM_PLAIN_RESPONSE,GCM_MULTIPLE_JSON_RESPONSE, GCM_PLAIN_RESPONSE_ERROR, 7 | GCM_JSON_RESPONSE_ERROR, GCM_PLAIN_RESPONSE_ERROR_B, GCM_JSON_RESPONSE_ERROR_B, 8 | GCM_PLAIN_CANONICAL_ID_RESPONSE, GCM_JSON_CANONICAL_ID_RESPONSE, 9 | GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE) 10 | from push_notifications.gcm import GCMError, gcm_send_bulk_message 11 | 12 | 13 | class ModelTestCase(TestCase): 14 | def test_can_save_gcm_device(self): 15 | device = GCMDevice.objects.create( 16 | registration_id="a valid registration id" 17 | ) 18 | assert device.id is not None 19 | assert device.date_created is not None 20 | assert device.date_created.date() == timezone.now().date() 21 | 22 | def test_can_create_save_device(self): 23 | device = APNSDevice.objects.create( 24 | registration_id="a valid registration id" 25 | ) 26 | assert device.id is not None 27 | assert device.date_created is not None 28 | assert device.date_created.date() == timezone.now().date() 29 | 30 | def test_gcm_send_message(self): 31 | device = GCMDevice.objects.create( 32 | registration_id="abc", 33 | ) 34 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: 35 | device.send_message("Hello world") 36 | p.assert_called_once_with( 37 | b"data.message=Hello+world®istration_id=abc", 38 | "application/x-www-form-urlencoded;charset=UTF-8") 39 | 40 | def test_gcm_send_message_extra(self): 41 | device = GCMDevice.objects.create( 42 | registration_id="abc", 43 | ) 44 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: 45 | device.send_message("Hello world", extra={"foo": "bar"}) 46 | p.assert_called_once_with( 47 | b"data.foo=bar&data.message=Hello+world®istration_id=abc", 48 | "application/x-www-form-urlencoded;charset=UTF-8") 49 | 50 | def test_gcm_send_message_collapse_key(self): 51 | device = GCMDevice.objects.create( 52 | registration_id="abc", 53 | ) 54 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: 55 | device.send_message("Hello world", collapse_key="test_key") 56 | p.assert_called_once_with( 57 | b"collapse_key=test_key&data.message=Hello+world®istration_id=abc", 58 | "application/x-www-form-urlencoded;charset=UTF-8") 59 | 60 | def test_gcm_send_message_to_multiple_devices(self): 61 | GCMDevice.objects.create( 62 | registration_id="abc", 63 | ) 64 | 65 | GCMDevice.objects.create( 66 | registration_id="abc1", 67 | ) 68 | 69 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: 70 | GCMDevice.objects.all().send_message("Hello world") 71 | p.assert_called_once_with( 72 | json.dumps({ 73 | "data": { "message": "Hello world" }, 74 | "registration_ids": ["abc", "abc1"] 75 | }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") 76 | 77 | def test_gcm_send_message_active_devices(self): 78 | GCMDevice.objects.create( 79 | registration_id="abc", 80 | active=True 81 | ) 82 | 83 | GCMDevice.objects.create( 84 | registration_id="xyz", 85 | active=False 86 | ) 87 | 88 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: 89 | GCMDevice.objects.all().send_message("Hello world") 90 | p.assert_called_once_with( 91 | json.dumps({ 92 | "data": { "message": "Hello world" }, 93 | "registration_ids": ["abc"] 94 | }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") 95 | 96 | def test_gcm_send_message_extra_to_multiple_devices(self): 97 | GCMDevice.objects.create( 98 | registration_id="abc", 99 | ) 100 | 101 | GCMDevice.objects.create( 102 | registration_id="abc1", 103 | ) 104 | 105 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: 106 | GCMDevice.objects.all().send_message("Hello world", extra={"foo": "bar"}) 107 | p.assert_called_once_with( 108 | json.dumps({ 109 | "data": { "foo": "bar", "message": "Hello world" }, 110 | "registration_ids": ["abc", "abc1"] 111 | }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") 112 | 113 | def test_gcm_send_message_collapse_to_multiple_devices(self): 114 | GCMDevice.objects.create( 115 | registration_id="abc", 116 | ) 117 | 118 | GCMDevice.objects.create( 119 | registration_id="abc1", 120 | ) 121 | 122 | with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: 123 | GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key") 124 | p.assert_called_once_with( 125 | json.dumps({ 126 | "collapse_key": "test_key", 127 | "data": { "message": "Hello world" }, 128 | "registration_ids": ["abc", "abc1"] 129 | }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") 130 | 131 | def test_gcm_send_message_to_single_device_with_error(self): 132 | # these errors are device specific, device.active will be set false 133 | device_list = ['abc', 'abc1'] 134 | self.create_devices(device_list) 135 | for index, error in enumerate(GCM_PLAIN_RESPONSE_ERROR): 136 | with mock.patch("push_notifications.gcm._gcm_send", 137 | return_value=error) as p: 138 | device = GCMDevice.objects. \ 139 | get(registration_id=device_list[index]) 140 | device.send_message("Hello World!") 141 | assert GCMDevice.objects.get(registration_id=device_list[index]).active is False 142 | 143 | def test_gcm_send_message_to_single_device_with_error_b(self): 144 | # these errors are not device specific, GCMError should be thrown 145 | device_list = ['abc'] 146 | self.create_devices(device_list) 147 | with mock.patch("push_notifications.gcm._gcm_send", 148 | return_value=GCM_PLAIN_RESPONSE_ERROR_B) as p: 149 | device = GCMDevice.objects. \ 150 | get(registration_id=device_list[0]) 151 | with self.assertRaises(GCMError): 152 | device.send_message("Hello World!") 153 | assert GCMDevice.objects.get(registration_id=device_list[0]).active is True 154 | 155 | def test_gcm_send_message_to_multiple_devices_with_error(self): 156 | device_list = ['abc', 'abc1', 'abc2'] 157 | self.create_devices(device_list) 158 | with mock.patch("push_notifications.gcm._gcm_send", 159 | return_value=GCM_JSON_RESPONSE_ERROR) as p: 160 | devices = GCMDevice.objects.all() 161 | devices.send_message("Hello World") 162 | assert GCMDevice.objects.get(registration_id=device_list[0]).active is False 163 | assert GCMDevice.objects.get(registration_id=device_list[1]).active is True 164 | assert GCMDevice.objects.get(registration_id=device_list[2]).active is False 165 | 166 | def test_gcm_send_message_to_multiple_devices_with_error_b(self): 167 | device_list = ['abc', 'abc1', 'abc2'] 168 | self.create_devices(device_list) 169 | with mock.patch("push_notifications.gcm._gcm_send", 170 | return_value=GCM_JSON_RESPONSE_ERROR_B) as p: 171 | devices = GCMDevice.objects.all() 172 | with self.assertRaises(GCMError): 173 | devices.send_message("Hello World") 174 | assert GCMDevice.objects.get(registration_id=device_list[0]).active is True 175 | assert GCMDevice.objects.get(registration_id=device_list[1]).active is True 176 | assert GCMDevice.objects.get(registration_id=device_list[2]).active is False 177 | 178 | def test_gcm_send_message_to_multiple_devices_with_canonical_id(self): 179 | device_list = ['foo', 'bar'] 180 | self.create_devices(device_list) 181 | with mock.patch("push_notifications.gcm._gcm_send", 182 | return_value=GCM_JSON_CANONICAL_ID_RESPONSE): 183 | GCMDevice.objects.all().send_message("Hello World") 184 | assert GCMDevice.objects.filter(registration_id=device_list[0]).exists() is False 185 | assert GCMDevice.objects.filter(registration_id=device_list[1]).exists() is True 186 | assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True 187 | 188 | def test_gcm_send_message_to_single_user_with_canonical_id(self): 189 | old_registration_id = 'foo' 190 | self.create_devices([old_registration_id]) 191 | with mock.patch("push_notifications.gcm._gcm_send", 192 | return_value=GCM_PLAIN_CANONICAL_ID_RESPONSE): 193 | GCMDevice.objects.get(registration_id=old_registration_id).send_message("Hello World") 194 | assert GCMDevice.objects.filter(registration_id=old_registration_id).exists() is False 195 | assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True 196 | 197 | def test_gcm_send_message_to_same_devices_with_canonical_id(self): 198 | device_list = ['foo', 'bar'] 199 | self.create_devices(device_list) 200 | first_device_pk = GCMDevice.objects.get(registration_id='foo').pk 201 | second_device_pk = GCMDevice.objects.get(registration_id='bar').pk 202 | with mock.patch("push_notifications.gcm._gcm_send", 203 | return_value=GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE): 204 | GCMDevice.objects.all().send_message("Hello World") 205 | first_device = GCMDevice.objects.get(pk=first_device_pk) 206 | second_device = GCMDevice.objects.get(pk=second_device_pk) 207 | assert first_device.active is False 208 | assert second_device.active is True 209 | 210 | def test_apns_send_message(self): 211 | device = APNSDevice.objects.create( 212 | registration_id="abc", 213 | ) 214 | socket = mock.MagicMock() 215 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 216 | device.send_message("Hello world", socket=socket, expiration=1) 217 | p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"}}', 0, 1, 10) 218 | 219 | def test_apns_send_message_extra(self): 220 | device = APNSDevice.objects.create( 221 | registration_id="abc", 222 | ) 223 | socket = mock.MagicMock() 224 | with mock.patch("push_notifications.apns._apns_pack_frame") as p: 225 | device.send_message("Hello world", extra={"foo": "bar"}, socket=socket, identifier=1, expiration=2, priority=5) 226 | p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"},"foo":"bar"}', 1, 2, 5) 227 | 228 | def test_send_message_with_no_reg_ids(self): 229 | device_list = ['abc', 'abc1'] 230 | self.create_devices(device_list) 231 | 232 | with mock.patch("push_notifications.gcm._gcm_send_plain", return_value='') as p: 233 | GCMDevice.objects.filter(registration_id='xyz').send_message('Hello World') 234 | p.assert_not_called() 235 | 236 | with mock.patch("push_notifications.gcm._gcm_send_json", return_value='') as p: 237 | reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()] 238 | gcm_send_bulk_message(reg_ids, {"message": "Hello World"}) 239 | p.assert_called_once_with([u"abc", u"abc1"], {"message": "Hello World"}) 240 | 241 | def create_devices(self, devices): 242 | for device in devices: 243 | GCMDevice.objects.create( 244 | registration_id=device, 245 | ) 246 | --------------------------------------------------------------------------------