├── .coveragerc ├── .gitignore ├── .landscape.yml ├── .travis.yml ├── AUTHORS ├── CHANGELOG.rst ├── LICENSE.txt ├── README.rst ├── pushy ├── __init__.py ├── admin.py ├── apps.py ├── contrib │ ├── __init__.py │ └── rest_api │ │ ├── __init__.py │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py ├── dispatchers.py ├── exceptions.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20141011_1703.py │ ├── 0003_auto_20150902_2001.py │ ├── 0004_auto_20160220_1828.py │ ├── 0005_auto_20160226_1946.py │ └── __init__.py ├── models.py ├── tasks.py └── utils.py ├── requirements.txt ├── requirements_dev.txt ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── data.py ├── settings.py ├── test_add_task.py ├── test_api.py ├── test_dispatchers.py ├── test_models.py ├── test_tasks.py └── urls.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pushy 4 | omit = *migrations*,*apps*,*admin.py* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | def __repr__ 10 | def __str__ 11 | def __unicode__ 12 | if self.debug: 13 | if settings.DEBUG 14 | raise AssertionError 15 | raise NotImplemented(Error)? 16 | if 0: 17 | if __name__ == .__main__.: 18 | 19 | [html] 20 | directory = coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .idea 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | -------------------------------------------------------------------------------- /.landscape.yml: -------------------------------------------------------------------------------- 1 | uses: 2 | - django 3 | - celery 4 | ignore-paths: 5 | - pushy/migrations 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.7 5 | - 3.4 6 | - 3.5 7 | - 3.6 8 | 9 | env: 10 | - DJANGO=1.8.9 11 | - DJANGO=1.9.2 12 | - DJANGO=1.10.7 13 | - DJANGO=1.11.4 14 | 15 | install: 16 | - pip install -q Django==$DJANGO 17 | - pip install -r requirements_dev.txt 18 | - pip install -q coveralls 19 | - pip install flake8 mock 20 | - python setup.py -q install 21 | 22 | before_script: 23 | - flake8 --exclude=migrations pushy 24 | 25 | script: 26 | - py.test -q 27 | 28 | after_success: 29 | - coveralls 30 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | https://github.com/rakanalh/django-pushy/graphs/contributors -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | v0.1.0 (2014-10-09) 2 | =================== 3 | * Initial release 4 | 5 | v0.1.1 (2014-10-09) 6 | =================== 7 | * Bug fixes 8 | 9 | v0.1.2 (2014-10-10) 10 | =================== 11 | * Django 1.7 Support 12 | 13 | v0.1.3 (2014-10-12) 14 | =================== 15 | * Added device queryset filters 16 | 17 | v0.1.4 (2014-10-21) 18 | =================== 19 | * Added Dict Payload 20 | 21 | v0.1.5 (2015-02-10) 22 | =================== 23 | * Added APNS support 24 | 25 | v0.1.6 (2015-04-22) 26 | =================== 27 | * Changed the default payload dispatch to use gcm.json_request as a default. See README.rst for config to change default. 28 | 29 | v0.1.7 (2015-05-05) 30 | =================== 31 | * Bug fixes in json response handling 32 | 33 | v0.1.8 (2015-05-27) 34 | =================== 35 | * Changed landscape lint problems / Changed license to MIT 36 | 37 | v0.1.9 (2015-10-06) 38 | =================== 39 | * Added new database fields date_started and date_finished for push notification 40 | * Notification are started using a chord with a group of tasks and a callback 41 | 42 | v0.1.10 (2016-02-22) 43 | ==================== 44 | * Dropped Python 2.6 support 45 | * Added Restful APIs to create & destroy devices using DRF 46 | 47 | v0.1.11 (2016-03-01) 48 | ==================== 49 | * Fixed a bug in migrations 50 | 51 | v0.1.12 (2016-11-19) 52 | ==================== 53 | * Fixed issue with existing canonical keys 54 | * Removed south migrations completely 55 | * Dropped support for Django 1.6 56 | 57 | v0.1.13 (2017-01-05) 58 | ==================== 59 | * GCMMismatchsenderidexception is caught and an error is reported 60 | 61 | v1.0.0 (2017-08-06) 62 | ==================== 63 | * Migrated from GCM and APNS libraries into pushjack 64 | * Python 3 support 65 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rakan Alhneiti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-pushy 2 | ============ 3 | Your push notifications handled at scale. 4 | 5 | .. image:: https://travis-ci.org/rakanalh/django-pushy.svg?branch=master 6 | :target: https://travis-ci.org/rakanalh/django-pushy 7 | .. image:: https://coveralls.io/repos/rakanalh/django-pushy/badge.png?branch=master 8 | :target: https://coveralls.io/r/rakanalh/django-pushy?branch=master 9 | .. image:: https://landscape.io/github/rakanalh/django-pushy/master/landscape.svg?style=flat 10 | :target: https://landscape.io/github/rakanalh/django-pushy/master 11 | :alt: Code Health 12 | 13 | What does it do? 14 | ---------------- 15 | Python / Django app that provides push notifications functionality with celery. The main purpose of this app is to help you send push notifications to your users at scale. If you have lots of registered device keys, django-pushy will split your keys into smaller groups which run in parallel making the process of sending notifications faster. 16 | 17 | Setup 18 | ----- 19 | You can install the library directly from pypi using pip:: 20 | 21 | $ pip install django-pushy 22 | 23 | 24 | Add django-pushy to your INSTALLED_APPS:: 25 | 26 | INSTALLED_APPS = ( 27 | ... 28 | "djcelery", 29 | "pushy" 30 | ) 31 | 32 | Configurations:: 33 | 34 | # Android 35 | PUSHY_GCM_API_KEY = 'YOUR_API_KEY_HERE' 36 | 37 | # Send JSON or plaintext payload to GCM server (default is JSON) 38 | PUSHY_GCM_JSON_PAYLOAD = True 39 | 40 | # iOS 41 | PUSHY_APNS_SANDBOX = True or False 42 | PUSHY_APNS_CERTIFICATE_FILE = 'PATH_TO_CERTIFICATE_FILE' 43 | 44 | PUSHY_QUEUE_DEFAULT_NAME = 'default' 45 | PUSHY_DEVICE_KEY_LIMIT = 1000 46 | 47 | 48 | Run DB migrations:: 49 | 50 | python manage.py migrate 51 | 52 | How do i use it? 53 | ---------------- 54 | 55 | There are 2 models provided by Pushy: 56 | 1) PushNotification 57 | 2) Device 58 | 59 | You have to implement your own code to use the Device model to register keys into the database:: 60 | 61 | from pushy.models import Device 62 | Device.objects.create(key='123', type=Device.DEVICE_TYPE_ANDROID, user=current_user) 63 | Device.objects.create(key='123', type=Device.DEVICE_TYPE_IOS, user=None) 64 | 65 | 66 | Whenever you need to push a notification, use the following code:: 67 | 68 | from pushy.utils import send_push_notification 69 | send_push_notification('Push_Notification_Title', {'key': 'value' ...}) 70 | 71 | This will send a push notification to all registered devices. 72 | You can also send a single notification to a single device:: 73 | 74 | device = Device.objects.get(pk=1) 75 | send_push_notification('YOUR TITLE', {YOUR_PAYLOAD}, device=device) 76 | 77 | 78 | Or you can use the filter_user or filter_type to make pushy send to a specified queryset of devices:: 79 | 80 | send_push_notification('YOUR TITLE', {YOUR_PAYLOAD}, filter_user=user) 81 | send_push_notification('YOUR TITLE', {YOUR_PAYLOAD}, filter_type=Device.DEVICE_TYPE_IOS) 82 | 83 | If you don't want to store the push notification into the database, you could pass in a keyword argument:: 84 | 85 | send_push_notification('YOUR_TITLE', {YOUR_PAYLOAD}, device=device, store=False) 86 | 87 | If you would like to add a push notification without triggering any action right away, you should be setting the property "payload 88 | instead of adding your dict to body as follows:: 89 | 90 | notification = PushNotification.objects.create( 91 | title=title, 92 | payload=payload, 93 | active=PushNotification.PUSH_ACTIVE, 94 | sent=PushNotification.PUSH_NOT_SENT 95 | ) 96 | 97 | As some serialization takes place to automatically convert the payload to a JSON string to be stored into the database. 98 | 99 | **iOS Users Note:** 100 | Please note that iOS special parameters: alert, sound, badge, content-available are all preset for you to None/False. Django-pushy ads payload you provide to the custom payload field. 101 | 102 | 103 | Restful APIs 104 | ------------ 105 | 106 | To use Restful APIs, pushy requires DRF >= 3.0.:: 107 | 108 | pip install django-pushy[apis] 109 | 110 | 111 | And add the following to your urls.py:: 112 | 113 | from pushy.contrib.rest_api import urls as pushy_rest_urls 114 | 115 | urlpatterns += [ 116 | url(r'^api/', include(rest_urls)) 117 | ] 118 | 119 | At this point you'll be able to make POST & DELETE requests to /api/pushy/device. An example request (using [httpie](https://github.com/jkbrzt/httpie) tool) to create a key is:: 120 | 121 | http http:///api/pushy/device/ key= type=ios --json 122 | 123 | To delete a key:: 124 | 125 | http delete http:///api/pushy/device/ key= --json 126 | 127 | Admin 128 | ----- 129 | Django-pushy also provides an admin interface to it's models so that you can add a push notification from admin. 130 | 131 | For that to work, you need to add "check_pending_push_notifications" task into your periodic tasks in celery admin. Make sure you setup:: 132 | 133 | djcelery.setup_loader() 134 | CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler' 135 | 136 | 137 | And don't forget to run celerybeat. 138 | 139 | Running the tests 140 | ----------------- 141 | Install mock:: 142 | 143 | pip install mock 144 | 145 | then run the following from the project's root:: 146 | 147 | py.test . 148 | 149 | 150 | License 151 | ------- 152 | 153 | MIT 154 | -------------------------------------------------------------------------------- /pushy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakanalh/django-pushy/5a38d13c16d5797bcb086eda8645bc96f93ecbaf/pushy/__init__.py -------------------------------------------------------------------------------- /pushy/admin.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.contrib import admin 3 | from django import forms 4 | 5 | from .models import PushNotification, Device 6 | 7 | 8 | class PushNotificationForm(forms.ModelForm): 9 | def clean(self): 10 | body = self.cleaned_data.get('body') 11 | try: 12 | body = json.loads(body) 13 | except ValueError: 14 | raise forms.ValidationError('Body does not contain valid JSON') 15 | return self.cleaned_data 16 | 17 | class Meta: 18 | model = PushNotification 19 | fields = ( 20 | 'title', 'body', 'active', 'sent', 'filter_type', 'filter_user' 21 | ) 22 | 23 | 24 | class PushNotificationAdmin(admin.ModelAdmin): 25 | form = PushNotificationForm 26 | list_display = ( 27 | 'title', 28 | 'date_created', 29 | 'active', 30 | 'sent', 31 | 'date_started', 32 | 'date_finished' 33 | ) 34 | list_filter = ('active', 'sent') 35 | search_fields = ('title', ) 36 | readonly_fields = ('date_started', 'date_finished') 37 | 38 | 39 | class DeviceAdmin(admin.ModelAdmin): 40 | list_display = ('key', ) 41 | list_filter = ('user', ) 42 | 43 | 44 | admin.site.register(PushNotification, PushNotificationAdmin) 45 | admin.site.register(Device, DeviceAdmin) 46 | -------------------------------------------------------------------------------- /pushy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PushyApp(AppConfig): 5 | name = 'pushy' 6 | verbose_name = 'Pushy' 7 | -------------------------------------------------------------------------------- /pushy/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakanalh/django-pushy/5a38d13c16d5797bcb086eda8645bc96f93ecbaf/pushy/contrib/__init__.py -------------------------------------------------------------------------------- /pushy/contrib/rest_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakanalh/django-pushy/5a38d13c16d5797bcb086eda8645bc96f93ecbaf/pushy/contrib/rest_api/__init__.py -------------------------------------------------------------------------------- /pushy/contrib/rest_api/serializers.py: -------------------------------------------------------------------------------- 1 | from pushy.models import Device 2 | from rest_framework import serializers 3 | 4 | 5 | def get_types_map(): 6 | return { 7 | device_type[1].lower(): device_type[0] 8 | for device_type in Device.DEVICE_TYPE_CHOICES 9 | } 10 | 11 | 12 | class DeviceSerializer(serializers.ModelSerializer): 13 | type = serializers.ChoiceField(choices=get_types_map(), required=True) 14 | user = serializers.PrimaryKeyRelatedField(read_only=True) 15 | 16 | def validate_type(self, value): 17 | types_map = get_types_map() 18 | return types_map[value] 19 | 20 | class Meta: 21 | model = Device 22 | fields = ('key', 'type', 'user') 23 | -------------------------------------------------------------------------------- /pushy/contrib/rest_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import DeviceViewSet 4 | 5 | urlpatterns = [ 6 | url(r'^pushy/device/$', 7 | DeviceViewSet.as_view({ 8 | 'post': 'create', 9 | 'delete': 'destroy' 10 | }), 11 | name='pushy-devices'), 12 | ] 13 | -------------------------------------------------------------------------------- /pushy/contrib/rest_api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from rest_framework import status 3 | from rest_framework.response import Response 4 | from rest_framework.settings import api_settings 5 | 6 | from pushy.models import Device 7 | 8 | from .serializers import DeviceSerializer 9 | 10 | 11 | class DeviceViewSet(viewsets.ViewSet): 12 | def create(self, request): 13 | serializer = DeviceSerializer(data=request.data) 14 | serializer.is_valid(raise_exception=True) 15 | serializer.save(user_id=request.user.id) 16 | 17 | return Response( 18 | request.data, 19 | status=status.HTTP_201_CREATED 20 | ) 21 | 22 | def destroy(self, request): 23 | try: 24 | key = request.data.get('key', None) 25 | Device.objects.get(key=key).delete() 26 | 27 | return Response( 28 | request.data, 29 | status=status.HTTP_200_OK 30 | ) 31 | except Device.DoesNotExist: 32 | return self._not_found_response(key) 33 | 34 | def _not_found_response(self, key): 35 | errors_key = api_settings.NON_FIELD_ERRORS_KEY 36 | 37 | return Response(data={ 38 | errors_key: ['Key {} was not found'.format(key)] 39 | }, status=status.HTTP_404_NOT_FOUND) 40 | -------------------------------------------------------------------------------- /pushy/dispatchers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from django.conf import settings 3 | from pushjack import ( 4 | APNSClient, 5 | APNSSandboxClient, 6 | GCMClient 7 | ) 8 | from pushjack.exceptions import ( 9 | GCMAuthError, 10 | GCMMissingRegistrationError, 11 | GCMInvalidRegistrationError, 12 | GCMUnregisteredDeviceError, 13 | GCMInvalidPackageNameError, 14 | GCMMismatchedSenderError, 15 | GCMMessageTooBigError, 16 | GCMInvalidDataKeyError, 17 | GCMInvalidTimeToLiveError, 18 | GCMTimeoutError, 19 | GCMInternalServerError, 20 | GCMDeviceMessageRateExceededError, 21 | 22 | APNSAuthError, 23 | APNSProcessingError, 24 | APNSMissingTokenError, 25 | APNSMissingTopicError, 26 | APNSMissingPayloadError, 27 | APNSInvalidTokenSizeError, 28 | APNSInvalidTopicSizeError, 29 | APNSInvalidPayloadSizeError, 30 | APNSInvalidTokenError, 31 | APNSShutdownError, 32 | APNSUnknownError 33 | ) 34 | 35 | from .models import Device 36 | from .exceptions import ( 37 | PushAuthException, 38 | PushInvalidTokenException, 39 | PushInvalidDataException, 40 | PushServerException 41 | ) 42 | 43 | dispatchers_cache = {} 44 | 45 | 46 | class Dispatcher(object): 47 | def send(self, device_key, data): 48 | raise NotImplementedError() 49 | 50 | 51 | class APNSDispatcher(Dispatcher): 52 | def __init__(self): 53 | super(APNSDispatcher, self).__init__() 54 | self._client = None 55 | 56 | @property 57 | def cert_file(self): 58 | return getattr(settings, 'PUSHY_APNS_CERTIFICATE_FILE', None) 59 | 60 | @property 61 | def use_sandbox(self): 62 | return bool(getattr(settings, 'PUSHY_APNS_SANDBOX', False)) 63 | 64 | def establish_connection(self): 65 | if self.cert_file is None: 66 | raise PushAuthException('Missing APNS certificate error') 67 | 68 | target_class = APNSClient 69 | if self.use_sandbox: 70 | target_class = APNSSandboxClient 71 | 72 | self._client = target_class( 73 | certificate=self.cert_file, 74 | default_error_timeout=10, 75 | default_expiration_offset=2592000, 76 | default_batch_size=100 77 | ) 78 | 79 | def _send(self, token, notification_payload): 80 | # pop causes a bug in altering the original payload 81 | # which causes title and message to be empty 82 | # for notifications following the currrent one. 83 | payload = copy.deepcopy(notification_payload) 84 | 85 | try: 86 | response = self._client.send( 87 | [token], 88 | title=payload.pop('title', None), 89 | message=payload.pop('message', None), 90 | sound=payload.pop('sound', None), 91 | badge=payload.pop('badge', None), 92 | category=payload.pop('category', None), 93 | content_available=True, 94 | extra=payload or {} 95 | ) 96 | 97 | if response.errors: 98 | raise response.errors.pop() 99 | return None 100 | 101 | except APNSAuthError: 102 | raise PushAuthException() 103 | 104 | except (APNSMissingTokenError, 105 | APNSInvalidTokenError): 106 | raise PushInvalidTokenException() 107 | 108 | except (APNSProcessingError, 109 | APNSMissingTopicError, 110 | APNSMissingPayloadError, 111 | APNSInvalidTokenSizeError, 112 | APNSInvalidTopicSizeError, 113 | APNSInvalidPayloadSizeError): 114 | raise PushInvalidDataException() 115 | 116 | except (APNSShutdownError, 117 | APNSUnknownError): 118 | raise PushServerException() 119 | 120 | except: 121 | raise PushServerException() 122 | 123 | def send(self, device_key, payload): 124 | if not self._client: 125 | self.establish_connection() 126 | 127 | return self._send(device_key, payload) 128 | 129 | 130 | class GCMDispatcher(Dispatcher): 131 | def __init__(self, api_key=None): 132 | if not api_key: 133 | api_key = getattr(settings, 'PUSHY_GCM_API_KEY', None) 134 | self._api_key = api_key 135 | 136 | def _send(self, device_key, payload): 137 | if not self._api_key: 138 | raise PushAuthException() 139 | 140 | gcm_client = GCMClient(self._api_key) 141 | try: 142 | response = gcm_client.send( 143 | [device_key], 144 | payload 145 | ) 146 | 147 | if response.errors: 148 | raise response.errors.pop() 149 | 150 | canonical_id = None 151 | if response.canonical_ids: 152 | canonical_id = response.canonical_ids[0].new_id 153 | return canonical_id 154 | 155 | except GCMAuthError: 156 | raise PushAuthException() 157 | 158 | except (GCMMissingRegistrationError, 159 | GCMInvalidRegistrationError, 160 | GCMUnregisteredDeviceError): 161 | raise PushInvalidTokenException() 162 | 163 | except (GCMInvalidPackageNameError, 164 | GCMMismatchedSenderError, 165 | GCMMessageTooBigError, 166 | GCMInvalidDataKeyError, 167 | GCMInvalidTimeToLiveError): 168 | raise PushInvalidDataException() 169 | 170 | except (GCMTimeoutError, 171 | GCMInternalServerError, 172 | GCMDeviceMessageRateExceededError): 173 | raise PushServerException() 174 | 175 | except: 176 | raise PushServerException() 177 | 178 | def send(self, device_key, payload): 179 | return self._send(device_key, payload) 180 | 181 | 182 | def get_dispatcher(device_type): 183 | if device_type in dispatchers_cache and dispatchers_cache[device_type]: 184 | return dispatchers_cache[device_type] 185 | 186 | if device_type == Device.DEVICE_TYPE_ANDROID: 187 | dispatchers_cache[device_type] = GCMDispatcher() 188 | else: 189 | dispatchers_cache[device_type] = APNSDispatcher() 190 | 191 | return dispatchers_cache[device_type] 192 | -------------------------------------------------------------------------------- /pushy/exceptions.py: -------------------------------------------------------------------------------- 1 | class PushException(Exception): 2 | pass 3 | 4 | 5 | class PushAuthException(PushException): 6 | pass 7 | 8 | 9 | class PushInvalidTokenException(PushException): 10 | pass 11 | 12 | 13 | class PushInvalidDataException(PushException): 14 | pass 15 | 16 | 17 | class PushServerException(PushException): 18 | pass 19 | -------------------------------------------------------------------------------- /pushy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Device', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('key', models.TextField()), 20 | ('type', models.SmallIntegerField(choices=[(1, 'Android'), (2, 'iOS')])), 21 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), 22 | ], 23 | options={ 24 | }, 25 | bases=(models.Model,), 26 | ), 27 | migrations.CreateModel( 28 | name='PushNotification', 29 | fields=[ 30 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 31 | ('title', models.CharField(max_length=50)), 32 | ('body', models.TextField()), 33 | ('active', models.SmallIntegerField(default=1, choices=[(1, 'Active'), (0, 'Inactive')])), 34 | ('sent', models.SmallIntegerField(default=0, choices=[(0, 'Not Sent'), (1, 'Sent')])), 35 | ('date_created', models.DateTimeField(auto_now_add=True)), 36 | ], 37 | options={ 38 | }, 39 | bases=(models.Model,), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /pushy/migrations/0002_auto_20141011_1703.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('pushy', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='pushnotification', 16 | name='filter_type', 17 | field=models.SmallIntegerField(default=0, blank=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AddField( 21 | model_name='pushnotification', 22 | name='filter_user', 23 | field=models.IntegerField(default=0, blank=True), 24 | preserve_default=True, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /pushy/migrations/0003_auto_20150902_2001.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('pushy', '0002_auto_20141011_1703'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='pushnotification', 16 | name='date_finished', 17 | field=models.DateTimeField(null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='pushnotification', 21 | name='date_started', 22 | field=models.DateTimeField(null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /pushy/migrations/0004_auto_20160220_1828.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-02-20 18:28 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 | ('pushy', '0003_auto_20150902_2001'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='pushnotification', 17 | name='sent', 18 | field=models.SmallIntegerField(choices=[(0, 'Not Sent'), (2, 'In Progress'), (1, 'Sent')], default=0), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /pushy/migrations/0005_auto_20160226_1946.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-02-26 19:46 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 | ('pushy', '0004_auto_20160220_1828'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='device', 17 | name='key', 18 | field=models.CharField(max_length=255), 19 | ), 20 | migrations.AlterUniqueTogether( 21 | name='device', 22 | unique_together=set([('key', 'type')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /pushy/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakanalh/django-pushy/5a38d13c16d5797bcb086eda8645bc96f93ecbaf/pushy/migrations/__init__.py -------------------------------------------------------------------------------- /pushy/models.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | 8 | class PushNotification(models.Model): 9 | PUSH_INACTIVE = 0 10 | PUSH_ACTIVE = 1 11 | 12 | PUSH_NOT_SENT = 0 13 | PUSH_SENT = 1 14 | PUSH_IN_PROGRESS = 2 15 | 16 | PUSH_CHOICES = ( 17 | (PUSH_ACTIVE, _('Active')), 18 | (PUSH_INACTIVE, _('Inactive')) 19 | ) 20 | 21 | PUSH_SENT_CHOICES = ( 22 | (PUSH_NOT_SENT, _('Not Sent')), 23 | (PUSH_IN_PROGRESS, _('In Progress')), 24 | (PUSH_SENT, _('Sent')) 25 | ) 26 | 27 | title = models.CharField(max_length=50) 28 | body = models.TextField() 29 | 30 | active = models.SmallIntegerField(choices=PUSH_CHOICES, 31 | default=PUSH_ACTIVE) 32 | sent = models.SmallIntegerField(choices=PUSH_SENT_CHOICES, 33 | default=PUSH_NOT_SENT) 34 | 35 | date_created = models.DateTimeField(auto_now_add=True) 36 | date_started = models.DateTimeField(null=True) 37 | date_finished = models.DateTimeField(null=True) 38 | filter_type = models.SmallIntegerField(blank=True, default=0) 39 | filter_user = models.IntegerField(blank=True, default=0) 40 | 41 | @property 42 | def payload(self): 43 | if self.body: 44 | return json.loads(self.body) 45 | return None 46 | 47 | @payload.setter 48 | def payload(self, value): 49 | self.body = json.dumps(value) 50 | 51 | def to_dict(self): 52 | # return notification as a dictionary 53 | # not including duplicate or model related fields 54 | data = copy.deepcopy(self.__dict__) 55 | data['payload'] = self.payload 56 | del data['body'] 57 | del data['_state'] 58 | return data 59 | 60 | def __unicode__(self): 61 | return self.title 62 | 63 | 64 | class Device(models.Model): 65 | DEVICE_TYPE_ANDROID = 1 66 | DEVICE_TYPE_IOS = 2 67 | DEVICE_TYPE_CHOICES = ( 68 | (DEVICE_TYPE_ANDROID, 'Android'), 69 | (DEVICE_TYPE_IOS, 'iOS') 70 | ) 71 | 72 | key = models.CharField(max_length=255) 73 | type = models.SmallIntegerField(choices=DEVICE_TYPE_CHOICES) 74 | user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) 75 | 76 | class Meta: 77 | unique_together = ('key', 'type') 78 | 79 | def __unicode__(self): 80 | # Reverse choices 81 | device_choices = dict(reversed(self.DEVICE_TYPE_CHOICES)) 82 | return "{} Device ID: {}".format(device_choices[self.type], self.pk) 83 | 84 | 85 | def get_filtered_devices_queryset(notification): 86 | devices = Device.objects.all() 87 | 88 | if 'filter_type' in notification and notification['filter_type']: 89 | devices = devices.filter(type=notification['filter_type']) 90 | if 'filter_user' in notification and notification['filter_user']: 91 | devices = devices.filter(user_id=notification['filter_user']) 92 | 93 | return devices 94 | -------------------------------------------------------------------------------- /pushy/tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import celery 5 | 6 | from django.conf import settings 7 | from django.db import transaction 8 | from django.db.utils import IntegrityError 9 | from django.utils import timezone 10 | 11 | from .models import ( 12 | PushNotification, 13 | Device, 14 | get_filtered_devices_queryset 15 | ) 16 | from .exceptions import ( 17 | PushInvalidTokenException, 18 | PushException 19 | ) 20 | from .dispatchers import get_dispatcher 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | @celery.shared_task( 27 | queue=getattr(settings, 'PUSHY_QUEUE_DEFAULT_NAME', None) 28 | ) 29 | def check_pending_push_notifications(): 30 | pending_notifications = PushNotification.objects.filter( 31 | sent=PushNotification.PUSH_NOT_SENT 32 | ) 33 | 34 | for pending_notification in pending_notifications: 35 | create_push_notification_groups.apply_async(kwargs={ 36 | 'notification': pending_notification.to_dict() 37 | }) 38 | 39 | 40 | @celery.shared_task( 41 | queue=getattr(settings, 'PUSHY_QUEUE_DEFAULT_NAME', None) 42 | ) 43 | def create_push_notification_groups(notification): 44 | devices = get_filtered_devices_queryset(notification) 45 | 46 | date_started = timezone.now() 47 | 48 | if devices.count() > 0: 49 | count = devices.count() 50 | limit = getattr(settings, 'PUSHY_DEVICE_KEY_LIMIT', 1000) 51 | celery.chord( 52 | send_push_notification_group.s(notification, offset, limit) 53 | for offset in range(0, count, limit) 54 | )(notify_push_notification_sent.si(notification)) 55 | 56 | if not notification['id']: 57 | return 58 | 59 | try: 60 | notification = PushNotification.objects.get(pk=notification['id']) 61 | notification.sent = PushNotification.PUSH_IN_PROGRESS 62 | notification.date_started = date_started 63 | notification.save() 64 | except PushNotification.DoesNotExist: 65 | return 66 | 67 | 68 | @celery.shared_task( 69 | queue=getattr(settings, 'PUSHY_QUEUE_DEFAULT_NAME', None) 70 | ) 71 | def send_push_notification_group(notification, offset=0, limit=1000): 72 | devices = get_filtered_devices_queryset(notification) 73 | 74 | devices = devices[offset:offset + limit] 75 | 76 | for device in devices: 77 | send_single_push_notification(device, notification['payload']) 78 | 79 | return True 80 | 81 | 82 | @celery.shared_task( 83 | queue=getattr(settings, 'PUSHY_QUEUE_DEFAULT_NAME', None) 84 | ) 85 | def send_single_push_notification(device, payload): 86 | # The task can be called in two ways: 87 | # 1) from send_push_notification_group directly with a device instance 88 | # 2) As a task using .delay or apply_async with a device id 89 | if isinstance(device, int): 90 | try: 91 | device = Device.objects.get(pk=device) 92 | except Device.DoesNotExist: 93 | return False 94 | 95 | dispatcher = get_dispatcher(device.type) 96 | 97 | try: 98 | canonical_id = dispatcher.send(device.key, payload) 99 | if not canonical_id: 100 | return 101 | 102 | with transaction.atomic(): 103 | device.key = canonical_id 104 | device.save() 105 | 106 | except IntegrityError: 107 | device.delete() 108 | except PushInvalidTokenException: 109 | logger.debug('Token for device {} does not exist, skipping'.format( 110 | device.id 111 | )) 112 | device.delete() 113 | except PushException: 114 | logger.exception("An error occured while sending push notification") 115 | return 116 | 117 | 118 | @celery.shared_task( 119 | queue=getattr(settings, 'PUSH_QUEUE_DEFAULT_NAME', None), 120 | ) 121 | def notify_push_notification_sent(notification): 122 | if not notification['id']: 123 | return False 124 | 125 | try: 126 | notification = PushNotification.objects.get(pk=notification['id']) 127 | notification.date_finished = timezone.now() 128 | notification.sent = PushNotification.PUSH_SENT 129 | notification.save() 130 | except PushNotification.DoesNotExist: 131 | logger.exception("Notification {} does not exist".format(notification)) 132 | return False 133 | 134 | 135 | @celery.shared_task( 136 | queue=getattr(settings, 'PUSH_QUEUE_DEFAULT_NAME', None) 137 | ) 138 | def clean_sent_notifications(): 139 | max_age = getattr(settings, 'PUSHY_NOTIFICATION_MAX_AGE', None) 140 | 141 | if not max_age or not isinstance(max_age, datetime.timedelta): 142 | raise ValueError('Notification max age value is not defined.') 143 | 144 | delete_before_date = timezone.now() - max_age 145 | PushNotification.objects.filter( 146 | sent=PushNotification.PUSH_SENT, 147 | date_finished__lt=delete_before_date 148 | ).delete() 149 | -------------------------------------------------------------------------------- /pushy/utils.py: -------------------------------------------------------------------------------- 1 | from .models import PushNotification 2 | from .tasks import ( 3 | send_single_push_notification, 4 | create_push_notification_groups 5 | ) 6 | 7 | 8 | def send_push_notification(title, payload, device=None, 9 | filter_user=None, filter_type=None, 10 | store=True): 11 | 12 | if not filter_type: 13 | filter_type = 0 14 | if not filter_user: 15 | filter_user = 0 16 | 17 | notification = PushNotification( 18 | title=title, 19 | payload=payload, 20 | active=PushNotification.PUSH_ACTIVE, 21 | sent=PushNotification.PUSH_NOT_SENT, 22 | filter_user=filter_user, 23 | filter_type=filter_type 24 | ) 25 | if store: 26 | notification.save() 27 | 28 | if device: 29 | # Send a single push notification immediately 30 | send_single_push_notification.apply_async(kwargs={ 31 | 'device': device.id, 32 | 'notification': notification.to_dict() 33 | }) 34 | return notification 35 | 36 | create_push_notification_groups.delay(notification=notification.to_dict()) 37 | 38 | return notification 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | django-celery<4.0 3 | pushjack==1.3.0 4 | djangorestframework<3.7.0 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | mock 3 | flake8 4 | pytest 5 | pytest-django 6 | pytest-cov 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='Django-Pushy', 5 | version='1.0.2', 6 | author='Rakan Alhneiti', 7 | author_email='rakan.alhneiti@gmail.com', 8 | 9 | # Packages 10 | packages=[ 11 | 'pushy', 12 | 'pushy/contrib', 13 | 'pushy/contrib/rest_api', 14 | 'pushy/migrations', 15 | ], 16 | include_package_data=True, 17 | 18 | # Details 19 | url='https://github.com/rakanalh/django-pushy', 20 | 21 | license='LICENSE.txt', 22 | description='Handle push notifications at scale.', 23 | long_description=open('README.rst').read(), 24 | 25 | # Dependent packages (distributions) 26 | install_requires=[ 27 | 'django>=1.6', 28 | 'django-celery==3.1.17', 29 | 'pushjack==1.3.0' 30 | ], 31 | extras_require={ 32 | 'rest_api': ['djangorestframework<3.7.0'] 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakanalh/django-pushy/5a38d13c16d5797bcb086eda8645bc96f93ecbaf/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def pytest_configure(): 5 | settings.configure( 6 | ROOT_URLCONF='tests.urls', 7 | 8 | ALLOWED_HOSTS=['testserver'], 9 | 10 | DATABASES={ 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': 'test_db' 14 | } 15 | }, 16 | 17 | INSTALLED_APPS=[ 18 | 'django.contrib.auth', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 22 | 'rest_framework', 23 | 'pushy', 24 | 'tests' 25 | ], 26 | 27 | PUSHY_GCM_API_KEY='SOME_TEST_KEY', 28 | PUSHY_GCM_JSON_PAYLOAD=False, 29 | PUSHY_APNS_CERTIFICATE_FILE='/var/apns/certificate', 30 | PUSHY_APNS_SANDBOX=False 31 | ) 32 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | from pushjack import ( 2 | GCMCanonicalID 3 | ) 4 | 5 | 6 | class ResponseMock: 7 | def __init__(self, status_code): 8 | self.status_code = status_code 9 | self.errors = [] 10 | self.canonical_ids = [] 11 | 12 | 13 | def valid_response(): 14 | return ResponseMock(200) 15 | 16 | 17 | def valid_with_canonical_id_response(canonical_id): 18 | canonical_id_obj = GCMCanonicalID(canonical_id, canonical_id) 19 | response = ResponseMock(200) 20 | response.canonical_ids = [canonical_id_obj] 21 | return response 22 | 23 | 24 | def invalid_with_exception(exc): 25 | response = ResponseMock(400) 26 | response.errors.append(exc) 27 | return response 28 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # assert warnings are enabled 2 | import warnings 3 | 4 | warnings.simplefilter('ignore', Warning) 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | } 10 | } 11 | 12 | INSTALLED_APPS = [ 13 | 'django.contrib.admin', 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sessions', 17 | 'django.contrib.sites', 18 | 'pushy', 19 | ] 20 | 21 | MIDDLEWARE_CLASSES = [] 22 | 23 | SECRET_KEY = 'django-pushy-key' 24 | SITE_ID = 1 25 | ROOT_URLCONF = 'core.urls' 26 | 27 | CELERY_ALWAYS_EAGER = True 28 | PUSHY_GCM_API_KEY = 'blah-blah' 29 | 30 | PUSHY_GCM_JSON_PAYLOAD = False 31 | 32 | PUSHY_APNS_CERTIFICATE_FILE = 'aps_development.pem' 33 | PUSHY_APNS_KEY_FILE = 'private_key.pem' -------------------------------------------------------------------------------- /tests/test_add_task.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | import mock 3 | from django.test import TestCase 4 | from pushy.utils import send_push_notification 5 | from pushy.models import PushNotification, Device 6 | 7 | 8 | class AddTaskTestCase(TestCase): 9 | def setUp(self): 10 | self.payload = { 11 | 'key1': 'value1', 12 | 'key2': 'value2', 13 | } 14 | 15 | def test_add_task(self): 16 | mock_task = mock.Mock() 17 | 18 | with mock.patch('pushy.tasks.create_push_notification_groups.delay', 19 | new=mock_task) as mocked_task: 20 | send_push_notification('some test push notification', self.payload) 21 | notification = PushNotification.objects.latest('id') 22 | mocked_task.assert_called_once_with( 23 | notification=notification.to_dict() 24 | ) 25 | 26 | self.assertEquals(notification.payload, self.payload) 27 | 28 | def test_add_task_filter_device(self): 29 | device = Device.objects.create(key='TEST_DEVICE_KEY', 30 | type=Device.DEVICE_TYPE_IOS) 31 | 32 | mock_task = mock.Mock() 33 | with mock.patch('pushy.tasks.send_single_push_notification.apply_async', 34 | new=mock_task) as mocked_task: 35 | send_push_notification( 36 | 'some other test push notification', 37 | self.payload, 38 | device=device 39 | ) 40 | 41 | notification = PushNotification.objects.latest('id') 42 | 43 | mocked_task.assert_called_with(kwargs={ 44 | 'device': device.id, 45 | 'notification': notification.to_dict() 46 | }) 47 | 48 | def test_add_task_filter_on_user(self): 49 | user = get_user_model().objects.create_user( 50 | username='test_user', 51 | email='test_user@django-pushy.com', 52 | password='test_password' 53 | ) 54 | 55 | mock_task = mock.Mock() 56 | with mock.patch('pushy.tasks.create_push_notification_groups.delay', 57 | new=mock_task) as mocked_task: 58 | send_push_notification( 59 | 'some other test push notification', 60 | self.payload, 61 | filter_user=user.id 62 | ) 63 | 64 | notification = PushNotification.objects.latest('id') 65 | 66 | mocked_task.assert_called_with(notification=notification.to_dict()) 67 | self.assertEqual(notification.filter_user, user.id) 68 | self.assertEqual(notification.filter_type, 0) 69 | 70 | def test_add_task_filter_on_device_type(self): 71 | mock_task = mock.Mock() 72 | with mock.patch('pushy.tasks.create_push_notification_groups.delay', 73 | new=mock_task) as mocked_task: 74 | send_push_notification( 75 | 'some other test push notification', 76 | self.payload, 77 | filter_type=Device.DEVICE_TYPE_IOS 78 | ) 79 | 80 | notification = PushNotification.objects.latest('id') 81 | 82 | mocked_task.assert_called_with(notification=notification.to_dict()) 83 | self.assertEqual(notification.filter_user, 0) 84 | self.assertEqual(notification.filter_type, Device.DEVICE_TYPE_IOS) 85 | 86 | def test_add_task_filter_on_device_type_and_user(self): 87 | user = get_user_model().objects.create_user( 88 | username='test_user', 89 | email='test_user@django-pushy.com', 90 | password='test_password' 91 | ) 92 | 93 | mock_task = mock.Mock() 94 | with mock.patch('pushy.tasks.create_push_notification_groups.delay', 95 | new=mock_task) as mocked_task: 96 | send_push_notification( 97 | 'some other test push notification', self.payload, 98 | filter_type=Device.DEVICE_TYPE_IOS, 99 | filter_user=user.id 100 | ) 101 | 102 | notification = PushNotification.objects.latest('id') 103 | 104 | mocked_task.assert_called_with(notification=notification.to_dict()) 105 | self.assertEqual(notification.filter_user, user.id) 106 | self.assertEqual(notification.filter_type, Device.DEVICE_TYPE_IOS) 107 | 108 | def test_add_task_without_storage(self): 109 | with mock.patch('pushy.tasks.create_push_notification_groups.delay'): 110 | notification = send_push_notification( 111 | 'test', {}, 112 | filter_type=Device.DEVICE_TYPE_IOS, 113 | store=False 114 | ) 115 | 116 | self.assertIsNone(notification.id) 117 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from rest_framework import status 3 | from rest_framework.test import APITestCase 4 | 5 | 6 | class APITests(APITestCase): 7 | def setUp(self): 8 | self.email = 'test_user@some_domain.com' 9 | self.password = 'test_password' 10 | 11 | # self.user = User.objects.create_user(self.email, self.password) 12 | 13 | def test_create_device(self): 14 | data = {'key': 'KEY1', 'type': 'android'} 15 | 16 | response = self.create_device(data) 17 | 18 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 19 | self.assertEqual(response.data, data) 20 | 21 | def test_create_device_ios(self): 22 | data = {'key': 'KEY1', 'type': 'ios'} 23 | 24 | response = self.create_device(data) 25 | 26 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 27 | self.assertEqual(response.data, data) 28 | 29 | def test_create_device_unknown_type(self): 30 | data = {'key': 'KEY1', 'type': 'unknown'} 31 | 32 | response = self.create_device(data) 33 | 34 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 35 | self.assertEqual(response.data, {'type': ['"unknown" is not a valid choice.']}) 36 | 37 | def test_create_duplicate_device(self): 38 | data = {'key': 'KEY1', 'type': 'android'} 39 | 40 | self.create_device(data) 41 | 42 | response = self.create_device(data) 43 | 44 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 45 | self.assertEqual(response.data['non_field_errors'][0], 46 | 'The fields key, type must make a unique set.') 47 | 48 | def test_destroy_device(self): 49 | data = {'key': 'KEY1', 'type': 'ios'} 50 | self.create_device(data) 51 | response = self.destroy_device(data) 52 | self.assertEqual(response.status_code, status.HTTP_200_OK) 53 | 54 | def test_destroy_nonexistent_device(self): 55 | response = self.destroy_device({'key': 'does not exist'}) 56 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 57 | 58 | def create_device(self, data): 59 | url = reverse('pushy-devices') 60 | 61 | response = self.client.post(url, data, format='json') 62 | return response 63 | 64 | def destroy_device(self, data): 65 | url = reverse('pushy-devices') 66 | 67 | response = self.client.delete(url, data, format='json') 68 | return response 69 | -------------------------------------------------------------------------------- /tests/test_dispatchers.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from django.test import TestCase 4 | 5 | from pushjack.apns import APNSSandboxClient 6 | from pushjack.exceptions import ( 7 | GCMMissingRegistrationError, 8 | GCMInvalidPackageNameError, 9 | GCMTimeoutError, 10 | GCMAuthError, 11 | APNSAuthError, 12 | APNSMissingTokenError, 13 | APNSProcessingError, 14 | APNSShutdownError 15 | ) 16 | 17 | from pushy.exceptions import ( 18 | PushInvalidTokenException, 19 | PushInvalidDataException, 20 | PushAuthException, 21 | PushServerException 22 | ) 23 | 24 | from pushy.models import Device 25 | from pushy import dispatchers 26 | 27 | from .data import ( 28 | valid_response, 29 | valid_with_canonical_id_response, 30 | invalid_with_exception 31 | ) 32 | 33 | 34 | class DispatchersTestCase(TestCase): 35 | 36 | def test_check_cache(self): 37 | dispatchers.dispatchers_cache = {} 38 | 39 | # Test cache Android 40 | dispatcher1 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID) 41 | self.assertEquals(dispatchers.dispatchers_cache, {1: dispatcher1}) 42 | 43 | # Test cache iOS 44 | dispatcher2 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) 45 | self.assertEquals( 46 | dispatchers.dispatchers_cache, 47 | {1: dispatcher1, 2: dispatcher2} 48 | ) 49 | 50 | # Final check, fetching from cache 51 | dispatcher1 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID) 52 | dispatcher2 = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) 53 | self.assertEquals( 54 | dispatchers.dispatchers_cache, 55 | {1: dispatcher1, 2: dispatcher2} 56 | ) 57 | 58 | def test_dispatcher_types(self): 59 | # Double check the factory method returning the correct types 60 | self.assertIsInstance( 61 | dispatchers.get_dispatcher(Device.DEVICE_TYPE_ANDROID), 62 | dispatchers.GCMDispatcher 63 | ) 64 | self.assertIsInstance( 65 | dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS), 66 | dispatchers.APNSDispatcher 67 | ) 68 | 69 | 70 | class GCMDispatcherTestCase(TestCase): 71 | device_key = 'TEST_DEVICE_KEY' 72 | data = {'title': 'Test', 'body': 'Test body'} 73 | 74 | def test_constructor_with_api_key(self): 75 | dispatcher = dispatchers.GCMDispatcher(123) 76 | self.assertEquals(123, dispatcher._api_key) 77 | 78 | def test_send_with_no_api_key(self): 79 | # Check that we throw the proper exception 80 | # in case no API Key is specified 81 | with mock.patch('django.conf.settings.PUSHY_GCM_API_KEY', new=None): 82 | dispatcher = dispatchers.GCMDispatcher() 83 | self.assertRaises( 84 | PushAuthException, 85 | dispatcher.send, 86 | self.device_key, 87 | self.data 88 | ) 89 | 90 | def test_notification_sent(self): 91 | dispatcher = dispatchers.GCMDispatcher() 92 | with mock.patch('pushjack.GCMClient.send') as request_mock: 93 | request_mock.return_value = valid_response() 94 | dispatcher.send(self.device_key, self.data) 95 | self.assertTrue(request_mock.called) 96 | 97 | def test_notification_sent_with_canonical_id(self): 98 | dispatcher = dispatchers.GCMDispatcher() 99 | # Check result when canonical value is returned 100 | response_mock = mock.Mock() 101 | response_mock.return_value = valid_with_canonical_id_response(123123) 102 | with mock.patch('pushjack.GCMClient.send', new=response_mock): 103 | canonical_id = dispatcher.send(self.device_key, self.data) 104 | self.assertEquals(canonical_id, 123123) 105 | 106 | def test_invalid_token_exception(self): 107 | dispatcher = dispatchers.GCMDispatcher() 108 | # Check not registered exception 109 | response_mock = mock.Mock() 110 | response_mock.return_value = invalid_with_exception( 111 | GCMAuthError('') 112 | ) 113 | with mock.patch('pushjack.GCMClient.send', new=response_mock): 114 | self.assertRaises( 115 | PushAuthException, 116 | dispatcher.send, 117 | self.device_key, 118 | self.data 119 | ) 120 | 121 | def test_invalid_api_key_exception(self): 122 | dispatcher = dispatchers.GCMDispatcher() 123 | # Check not registered exception 124 | response_mock = mock.Mock() 125 | response_mock.return_value = invalid_with_exception( 126 | GCMMissingRegistrationError('') 127 | ) 128 | with mock.patch('pushjack.GCMClient.send', new=response_mock): 129 | self.assertRaises( 130 | PushInvalidTokenException, 131 | dispatcher.send, 132 | self.device_key, 133 | self.data 134 | ) 135 | 136 | def test_invalid_data_exception(self): 137 | dispatcher = dispatchers.GCMDispatcher() 138 | # Check not registered exception 139 | response_mock = mock.Mock() 140 | response_mock.return_value = invalid_with_exception( 141 | GCMInvalidPackageNameError('') 142 | ) 143 | with mock.patch('pushjack.GCMClient.send', new=response_mock): 144 | self.assertRaises( 145 | PushInvalidDataException, 146 | dispatcher.send, 147 | self.device_key, 148 | self.data 149 | ) 150 | 151 | def test_invalid_exception(self): 152 | dispatcher = dispatchers.GCMDispatcher() 153 | # Check not registered exception 154 | response_mock = mock.Mock() 155 | response_mock.return_value = invalid_with_exception( 156 | GCMTimeoutError('') 157 | ) 158 | with mock.patch('pushjack.GCMClient.send', new=response_mock): 159 | self.assertRaises( 160 | PushServerException, 161 | dispatcher.send, 162 | self.device_key, 163 | self.data 164 | ) 165 | 166 | 167 | class ApnsDispatcherTests(TestCase): 168 | device_key = 'TEST_DEVICE_KEY' 169 | data = {'alert': 'Test'} 170 | 171 | def setUp(self): 172 | self.dispatcher = dispatchers.get_dispatcher(Device.DEVICE_TYPE_IOS) 173 | 174 | @mock.patch('django.conf.settings.PUSHY_APNS_CERTIFICATE_FILE', new=None) 175 | def test_certificate_exception_on_send(self): 176 | self.assertRaises( 177 | PushAuthException, 178 | self.dispatcher.send, 179 | self.device_key, 180 | self.data 181 | ) 182 | 183 | @mock.patch('django.conf.settings.PUSHY_APNS_SANDBOX', new=True) 184 | def test_sandbox_client(self): 185 | dispatcher = dispatchers.APNSDispatcher() 186 | dispatcher.establish_connection() 187 | self.assertIsInstance(dispatcher._client, APNSSandboxClient) 188 | 189 | def test_invalid_token_exception(self): 190 | # Check not registered exception 191 | response_mock = mock.Mock() 192 | response_mock.return_value = invalid_with_exception( 193 | APNSMissingTokenError('') 194 | ) 195 | with mock.patch('pushjack.APNSClient.send', new=response_mock): 196 | self.assertRaises( 197 | PushInvalidTokenException, 198 | self.dispatcher.send, 199 | self.device_key, 200 | self.data 201 | ) 202 | 203 | def test_invalid_api_key_exception(self): 204 | # Check not registered exception 205 | response_mock = mock.Mock() 206 | response_mock.return_value = invalid_with_exception( 207 | APNSAuthError('') 208 | ) 209 | with mock.patch('pushjack.APNSClient.send', new=response_mock): 210 | self.assertRaises( 211 | PushAuthException, 212 | self.dispatcher.send, 213 | self.device_key, 214 | self.data 215 | ) 216 | 217 | def test_invalid_data_exception(self): 218 | # Check not registered exception 219 | response_mock = mock.Mock() 220 | response_mock.return_value = invalid_with_exception( 221 | APNSProcessingError('') 222 | ) 223 | with mock.patch('pushjack.APNSClient.send', new=response_mock): 224 | self.assertRaises( 225 | PushInvalidDataException, 226 | self.dispatcher.send, 227 | self.device_key, 228 | self.data 229 | ) 230 | 231 | def test_invalid_exception(self): 232 | # Check not registered exception 233 | response_mock = mock.Mock() 234 | response_mock.return_value = invalid_with_exception( 235 | APNSShutdownError('') 236 | ) 237 | with mock.patch('pushjack.APNSClient.send', new=response_mock): 238 | self.assertRaises( 239 | PushServerException, 240 | self.dispatcher.send, 241 | self.device_key, 242 | self.data 243 | ) 244 | 245 | def test_push_sent(self): 246 | apns = mock.Mock() 247 | apns.return_value = valid_response() 248 | with mock.patch('pushjack.APNSClient.send', new=apns): 249 | self.assertEqual( 250 | self.dispatcher.send(self.device_key, self.data), 251 | None 252 | ) 253 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from pushy.models import PushNotification 4 | 5 | 6 | class TasksTestCase(TestCase): 7 | def test_empty_payload(self): 8 | notification = PushNotification() 9 | self.assertEqual(None, notification.payload) 10 | 11 | def test_valid_payload(self): 12 | payload = { 13 | 'attr': 'value' 14 | } 15 | notification = PushNotification() 16 | notification.payload = payload 17 | 18 | self.assertEqual(payload, notification.payload) 19 | 20 | def test_to_dict(self): 21 | notification = PushNotification() 22 | self.assertTrue('_state' not in notification.to_dict()) 23 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import mock 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.test import TestCase 6 | from django.test.utils import override_settings 7 | from django.utils import timezone 8 | 9 | from pushy.models import ( 10 | PushNotification, 11 | Device, 12 | get_filtered_devices_queryset 13 | ) 14 | 15 | from pushy.exceptions import ( 16 | PushException, 17 | PushInvalidTokenException 18 | ) 19 | 20 | from pushy.tasks import ( 21 | check_pending_push_notifications, 22 | send_push_notification_group, 23 | send_single_push_notification, 24 | create_push_notification_groups, 25 | clean_sent_notifications, 26 | notify_push_notification_sent 27 | ) 28 | 29 | 30 | class TasksTestCase(TestCase): 31 | def setUp(self): 32 | self.payload = { 33 | 'key1': 'value1', 34 | 'key2': 'value2', 35 | } 36 | 37 | def test_get_filtered_devices_queryset_on_type(self): 38 | notification = PushNotification.objects.create( 39 | title='test', 40 | payload=self.payload, 41 | active=PushNotification.PUSH_ACTIVE, 42 | sent=PushNotification.PUSH_NOT_SENT, 43 | filter_type=Device.DEVICE_TYPE_IOS 44 | ) 45 | 46 | for i in range(0, 3): 47 | Device.objects.create( 48 | key='TEST_DEVICE_KEY_{}'.format(i), 49 | type=Device.DEVICE_TYPE_IOS 50 | ) 51 | 52 | for i in range(0, 10): 53 | Device.objects.create( 54 | key='TEST_DEVICE_KEY_{}'.format(i), 55 | type=Device.DEVICE_TYPE_ANDROID 56 | ) 57 | 58 | devices = get_filtered_devices_queryset(notification.to_dict()) 59 | self.assertEqual(devices.count(), 3) 60 | 61 | def test_get_filtered_devices_queryset_on_user(self): 62 | for i in range(0, 3): 63 | get_user_model().objects.create_user( 64 | username='test_user_%d' % i, 65 | email='test_user_%d@django-pushy.com' % i, 66 | password=i 67 | ) 68 | 69 | user1 = get_user_model().objects.get(pk=1) 70 | user2 = get_user_model().objects.get(pk=2) 71 | 72 | # Add 5 devices to user2 73 | for i in range(0, 5): 74 | Device.objects.create( 75 | key='TEST_DEVICE_KEY_{}'.format(i), 76 | type=Device.DEVICE_TYPE_ANDROID, 77 | user=user2 78 | ) 79 | 80 | # Check that user2 has 5 devices 81 | notification = PushNotification.objects.create( 82 | title='test', 83 | payload=self.payload, 84 | active=PushNotification.PUSH_ACTIVE, 85 | sent=PushNotification.PUSH_NOT_SENT, 86 | filter_user=user2.id 87 | ) 88 | 89 | devices = get_filtered_devices_queryset(notification.to_dict()) 90 | 91 | self.assertEqual(devices.count(), 5) 92 | 93 | # Check that user 1 has no devices 94 | notification = PushNotification.objects.create( 95 | title='test', 96 | payload=self.payload, 97 | active=PushNotification.PUSH_ACTIVE, 98 | sent=PushNotification.PUSH_NOT_SENT, 99 | filter_user=user1.id 100 | ) 101 | 102 | devices = get_filtered_devices_queryset(notification.to_dict()) 103 | 104 | self.assertEqual(devices.count(), 0) 105 | 106 | def test_pending_notifications(self): 107 | PushNotification.objects.create( 108 | title='test', 109 | payload=self.payload, 110 | active=PushNotification.PUSH_ACTIVE, 111 | sent=PushNotification.PUSH_NOT_SENT 112 | ) 113 | 114 | mocked_task = mock.Mock() 115 | with mock.patch( 116 | 'pushy.tasks.create_push_notification_groups.apply_async', 117 | new=mocked_task): 118 | check_pending_push_notifications() 119 | mocked_task.assert_called() 120 | 121 | def test_notifications_groups_chord(self): 122 | notification = PushNotification.objects.create( 123 | title='test', 124 | payload=self.payload, 125 | active=PushNotification.PUSH_ACTIVE, 126 | sent=PushNotification.PUSH_NOT_SENT 127 | ) 128 | 129 | # Create a test device key 130 | Device.objects.create( 131 | key='TEST_DEVICE_KEY_ANDROID', 132 | type=Device.DEVICE_TYPE_ANDROID 133 | ) 134 | 135 | mocked_task = mock.Mock() 136 | with mock.patch( 137 | 'celery.chord', 138 | new=mocked_task): 139 | create_push_notification_groups(notification.to_dict()) 140 | mocked_task.assert_called() 141 | 142 | def test_notifications_groups_return(self): 143 | notification = PushNotification.objects.create( 144 | title='test', 145 | payload=self.payload, 146 | active=PushNotification.PUSH_ACTIVE, 147 | sent=PushNotification.PUSH_NOT_SENT 148 | ) 149 | 150 | # Create a test device key 151 | Device.objects.create( 152 | key='TEST_DEVICE_KEY_ANDROID', 153 | type=Device.DEVICE_TYPE_ANDROID 154 | ) 155 | 156 | mocked_task = mock.Mock() 157 | with mock.patch( 158 | 'celery.chord', 159 | new=mocked_task): 160 | notification_dict = notification.to_dict() 161 | notification_dict['id'] = None 162 | create_push_notification_groups(notification_dict) 163 | 164 | notification = PushNotification.objects.get(pk=notification.id) 165 | self.assertEqual(PushNotification.PUSH_NOT_SENT, notification.sent) 166 | 167 | def test_send_notification_groups(self): 168 | notification = PushNotification.objects.create( 169 | title='test', 170 | payload=self.payload, 171 | active=PushNotification.PUSH_ACTIVE, 172 | sent=PushNotification.PUSH_NOT_SENT 173 | ) 174 | 175 | # Create a test device key 176 | device = Device.objects.create( 177 | key='TEST_DEVICE_KEY_ANDROID', 178 | type=Device.DEVICE_TYPE_ANDROID 179 | ) 180 | 181 | # Make sure canonical ID is saved 182 | gcm = mock.Mock() 183 | gcm.return_value = 123123 184 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 185 | send_push_notification_group(notification.to_dict(), 0, 1) 186 | 187 | device = Device.objects.get(pk=device.id) 188 | self.assertEqual(device.key, '123123') 189 | 190 | # Make sure the key is deleted when not registered exception is fired 191 | gcm = mock.Mock() 192 | gcm.side_effect = PushInvalidTokenException 193 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 194 | send_push_notification_group(notification.to_dict(), 0, 1) 195 | 196 | self.assertRaises( 197 | Device.DoesNotExist, 198 | Device.objects.get, 199 | pk=device.id 200 | ) 201 | 202 | # Create an another test device key 203 | device = Device.objects.create( 204 | key='TEST_DEVICE_KEY_ANDROID2', 205 | type=Device.DEVICE_TYPE_ANDROID 206 | ) 207 | 208 | # No canonical ID wasn't returned 209 | gcm = mock.Mock() 210 | gcm.return_value = False 211 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 212 | send_push_notification_group(notification.to_dict(), 0, 1) 213 | 214 | device = Device.objects.get(pk=device.id) 215 | self.assertEqual(device.key, 'TEST_DEVICE_KEY_ANDROID2') 216 | 217 | def test_delete_old_key_if_canonical_is_registered(self): 218 | notification = PushNotification.objects.create( 219 | title='test', 220 | payload=self.payload, 221 | active=PushNotification.PUSH_ACTIVE, 222 | sent=PushNotification.PUSH_NOT_SENT 223 | ) 224 | # Create a test device key 225 | device = Device.objects.create( 226 | key='TEST_DEVICE_KEY_ANDROID', 227 | type=Device.DEVICE_TYPE_ANDROID 228 | ) 229 | Device.objects.create(key='123123', type=Device.DEVICE_TYPE_ANDROID) 230 | 231 | # Make sure old device is deleted 232 | # if the new canonical ID already exists 233 | gcm = mock.Mock() 234 | gcm.return_value = '123123' 235 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 236 | send_push_notification_group(notification.to_dict(), 0, 1) 237 | 238 | self.assertFalse(Device.objects.filter(pk=device.id).exists()) 239 | 240 | def test_create_push_notification_groups_non_existent_notification(self): 241 | result = create_push_notification_groups({'id': 1000}) 242 | self.assertFalse(result) 243 | 244 | def test_non_existent_device(self): 245 | result = send_single_push_notification(1000, {'payload': 'test'}) 246 | self.assertFalse(result) 247 | 248 | def test_invalid_token_exception(self): 249 | # Create a test device key 250 | device = Device.objects.create( 251 | key='TEST_DEVICE_KEY_ANDROID', 252 | type=Device.DEVICE_TYPE_ANDROID 253 | ) 254 | gcm = mock.Mock() 255 | gcm.side_effect = PushInvalidTokenException 256 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 257 | send_single_push_notification(device, {'payload': 'test'}) 258 | self.assertRaises( 259 | Device.DoesNotExist, 260 | Device.objects.get, 261 | pk=device.id 262 | ) 263 | 264 | @mock.patch('pushy.tasks.logger.exception') 265 | def test_push_exception(self, logging_mock): 266 | # Create a test device key 267 | device = Device.objects.create( 268 | key='TEST_DEVICE_KEY_ANDROID', 269 | type=Device.DEVICE_TYPE_ANDROID 270 | ) 271 | gcm = mock.Mock() 272 | gcm.side_effect = PushException 273 | with mock.patch('pushy.dispatchers.GCMDispatcher.send', new=gcm): 274 | send_single_push_notification(device, {'payload': 'test'}) 275 | 276 | logging_mock.assert_called() 277 | 278 | def test_delete_old_notifications_undefine_max_age(self): 279 | self.assertRaises(ValueError, clean_sent_notifications) 280 | 281 | @override_settings(PUSHY_NOTIFICATION_MAX_AGE=datetime.timedelta(days=90)) 282 | def test_delete_old_notifications(self): 283 | for i in range(10): 284 | date_started = timezone.now() - datetime.timedelta(days=91) 285 | date_finished = date_started 286 | notification = PushNotification() 287 | notification.title = 'title {}'.format(i) 288 | notification.body = '{}' 289 | notification.sent = PushNotification.PUSH_SENT 290 | notification.date_started = date_started 291 | notification.date_finished = date_finished 292 | notification.save() 293 | 294 | clean_sent_notifications() 295 | 296 | self.assertEquals(PushNotification.objects.count(), 0) 297 | 298 | @override_settings(PUSHY_NOTIFICATION_MAX_AGE=datetime.timedelta(days=90)) 299 | def test_delete_old_notifications_with_remaining_onces(self): 300 | for i in range(10): 301 | date_started = timezone.now() - datetime.timedelta(days=91) 302 | date_finished = date_started 303 | notification = PushNotification() 304 | notification.title = 'title {}'.format(i) 305 | notification.body = '{}' 306 | notification.sent = PushNotification.PUSH_SENT 307 | notification.date_started = date_started 308 | notification.date_finished = date_finished 309 | notification.save() 310 | 311 | for i in range(10): 312 | date_started = timezone.now() - datetime.timedelta(days=61) 313 | date_finished = date_started 314 | notification = PushNotification() 315 | notification.title = 'title {}'.format(i) 316 | notification.body = '{}' 317 | notification.sent = PushNotification.PUSH_SENT 318 | notification.date_started = date_started 319 | notification.date_finished = date_finished 320 | notification.save() 321 | 322 | clean_sent_notifications() 323 | 324 | self.assertEquals(PushNotification.objects.count(), 10) 325 | 326 | def test_notify_notification_finished(self): 327 | notification = PushNotification.objects.create( 328 | title='test', 329 | payload=self.payload, 330 | active=PushNotification.PUSH_ACTIVE, 331 | sent=PushNotification.PUSH_NOT_SENT 332 | ) 333 | notification.save() 334 | notify_push_notification_sent(notification.to_dict()) 335 | 336 | notification = PushNotification.objects.get(pk=notification.id) 337 | self.assertEquals(PushNotification.PUSH_SENT, notification.sent) 338 | 339 | def test_notify_notification_does_not_exist(self): 340 | notification = PushNotification( 341 | id=1001, 342 | title='test', 343 | payload=self.payload, 344 | active=PushNotification.PUSH_ACTIVE, 345 | sent=PushNotification.PUSH_NOT_SENT 346 | ) 347 | 348 | notify_push_notification_sent(notification.to_dict()) 349 | self.assertEquals(PushNotification.PUSH_NOT_SENT, notification.sent) 350 | 351 | def test_notify_notification_does_nothing(self): 352 | notification = PushNotification( 353 | title='test', 354 | payload=self.payload, 355 | active=PushNotification.PUSH_ACTIVE, 356 | sent=PushNotification.PUSH_NOT_SENT 357 | ) 358 | 359 | self.assertFalse(notify_push_notification_sent(notification.to_dict())) 360 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | from pushy.contrib.rest_api import urls as rest_urls 5 | 6 | urlpatterns = [ 7 | url(r'^admin/', include(admin.site.urls)), 8 | url(r'^api/', include(rest_urls)) 9 | ] 10 | --------------------------------------------------------------------------------