From 7c850180f53e2dd7bf59bd33da7cf2d9c08f5eab Mon Sep 17 00:00:00 2001 From: Michael Fladischer Date: Fri, 3 Mar 2017 08:37:39 +0100 Subject: [PATCH] Import python-django-push-notifications_1.4.1.orig.tar.gz [dgit import orig python-django-push-notifications_1.4.1.orig.tar.gz] --- .gitignore | 17 ++ .travis.yml | 12 + AUTHORS | 44 ++++ CHANGELOG.rst | 101 ++++++++ CONTRIBUTING.md | 24 ++ LICENSE | 19 ++ MANIFEST.in | 3 + README.rst | 219 ++++++++++++++++ push_notifications/__init__.py | 8 + push_notifications/admin.py | 81 ++++++ push_notifications/api/__init__.py | 12 + push_notifications/api/rest_framework.py | 127 ++++++++++ push_notifications/api/tastypie.py | 42 ++++ push_notifications/apns.py | 238 ++++++++++++++++++ push_notifications/fields.py | 123 +++++++++ push_notifications/gcm.py | 194 ++++++++++++++ push_notifications/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/prune_devices.py | 16 ++ push_notifications/migrations/0001_initial.py | 48 ++++ .../migrations/0002_auto_20160106_0850.py | 20 ++ push_notifications/migrations/__init__.py | 0 push_notifications/models.py | 100 ++++++++ push_notifications/settings.py | 21 ++ requirements.txt | 1 + setup.cfg | 2 + setup.py | 40 +++ tests/__init__.py | 12 + tests/mock_responses.py | 17 ++ tests/runtests.py | 57 +++++ tests/settings.py | 23 ++ tests/test_apns_push_payload.py | 31 +++ tests/test_gcm_push_payload.py | 28 +++ tests/test_management_commands.py | 25 ++ tests/test_models.py | 232 +++++++++++++++++ tests/test_rest_framework.py | 120 +++++++++ tox.ini | 20 ++ 37 files changed, 2077 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 AUTHORS create mode 100644 CHANGELOG.rst create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 push_notifications/__init__.py create mode 100644 push_notifications/admin.py create mode 100644 push_notifications/api/__init__.py create mode 100644 push_notifications/api/rest_framework.py create mode 100644 push_notifications/api/tastypie.py create mode 100644 push_notifications/apns.py create mode 100644 push_notifications/fields.py create mode 100644 push_notifications/gcm.py create mode 100644 push_notifications/management/__init__.py create mode 100644 push_notifications/management/commands/__init__.py create mode 100644 push_notifications/management/commands/prune_devices.py create mode 100644 push_notifications/migrations/0001_initial.py create mode 100644 push_notifications/migrations/0002_auto_20160106_0850.py create mode 100644 push_notifications/migrations/__init__.py create mode 100644 push_notifications/models.py create mode 100644 push_notifications/settings.py create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/mock_responses.py create mode 100755 tests/runtests.py create mode 100644 tests/settings.py create mode 100644 tests/test_apns_push_payload.py create mode 100644 tests/test_gcm_push_payload.py create mode 100644 tests/test_management_commands.py create mode 100644 tests/test_models.py create mode 100644 tests/test_rest_framework.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38db1b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# python compiled +__pycache__ +*.pyc + +# distutils +MANIFEST +build + +# IDE +.idea +*.iml + +# virtualenv +.env + +# tox +.tox diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..eb155b3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python +addons: + apt: + sources: + - deadsnakes + packages: + - python3.5 +install: + - pip install tox +script: + - tox +sudo: false diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..037ef5e --- /dev/null +++ b/AUTHORS @@ -0,0 +1,44 @@ +This library was created by Jerome Leclanche , for use on the +Anthill application (https://www.anthill.com). + +Special thanks to everyone who contributed: + +Adam "Cezar" Jenkins +Alan Descoins +Ales Dokhanin +Alistair Broomhead +Andrey Zevakin +Antonin Lenfant +Arthur Silva +Avichal Pandey +Brad Pitcher +Daniel Kronovet +David Pretty +Dilvane Zanardine +Florian Finke +Florian Purchess +Francois Lebel +halak +Innocenty Enikeew +Jack Feng +Jamaal Scarlett +Jay Camp +Jeremy Morgan +Jerome Leclanche +Julien Dubiel +Lital Natan +Luke Burden +Marconi Moreto +Matthew Hershberger +Maxim Kamenkov +Mohamad Nour Chawich +Nicolas Delaby +Remigiusz Dymecki +Ruslan Kovtun +Sander Heling +Sergei Evdokimov +Sujit Nair +Thomas Iovine +Valentin Hăloiu +Wyan Jow +@hoongun diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..17a1580 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,101 @@ +v1.4.1 (2016-01-11) +=================== +* APNS: Increased max device token size to 100 bytes (WWDC 2015, iOS 9) +* BUGFIX: Fix an index error in the admin + +v1.4.0 (2015-12-13) +=================== +* BACKWARDS-INCOMPATIBLE: Drop support for Python<3.4 +* DJANGO: Support Django 1.9 +* GCM: Handle canonical IDs +* GCM: Allow full range of GCMDevice.device_id values +* GCM: Do not allow duplicate registration_ids +* DRF: Work around empty boolean defaults issue (django-rest-framework#1101) +* BUGFIX: Do not throw GCMError in bulk messages from the admin +* BUGFIX: Avoid generating an extra migration on Python 3 +* BUGFIX: Only send in bulk to active devices +* BUGFIX: Display models correctly in the admin on both Python 2 and 3 + + +v1.3.1 (2015-06-30) +=================== +This is an errata release. + +v1.3.0 (2015-06-30) +=================== +* BACKWARDS-INCOMPATIBLE: Drop support for Python<2.7 +* BACKWARDS-INCOMPATIBLE: Drop support for Django<1.8 +* NEW FEATURE: Added a Django Rest Framework API. Requires DRF>=3.0. +* APNS: Add support for setting the ca_certs file with new APNS_CA_CERTIFICATES setting +* GCM: Deactivate GCMDevices when their notifications cause NotRegistered or InvalidRegistration +* GCM: Indiscriminately handle all keyword arguments in gcm_send_message and gcm_send_bulk_message +* GCM: Never fall back to json in gcm_send_message +* BUGFIX: Fixed migration issues from 1.2.0 upgrade. +* BUGFIX: Better detection of SQLite/GIS MySQL in various checks +* BUGFIX: Assorted Python 3 bugfixes +* BUGFIX: Fix display of device_id in admin + +v1.2.1 (2015-04-11) +=================== +* APNS, GCM: Add a db_index to the device_id field +* APNS: Use the native UUIDField on Django 1.8 +* APNS: Fix timeout handling on Python 3 +* APNS: Restore error checking on apns_send_bulk_message +* GCM: Expose the time_to_live argument in gcm_send_bulk_message +* GCM: Fix return value when gcm bulk is split in batches +* GCM: Improved error checking reliability +* GCM: Properly pass kwargs in GCMDeviceQuerySet.send_message() +* BUGFIX: Fix HexIntegerField for Django 1.3 + +v1.2.0 (2014-10-07) +=================== +* BACKWARDS-INCOMPATIBLE: Added support for Django 1.7 migrations. South users will have to upgrade to South 1.0 or Django 1.7. +* APNS: APNS MAX_NOTIFICATION_SIZE is now a setting and its default has been increased to 2048 +* APNS: Always connect with TLSv1 instead of SSLv3 +* APNS: Implemented support for APNS Feedback Service +* APNS: Support for optional "category" dict +* GCM: Improved error handling in bulk mode +* GCM: Added support for time_to_live parameter +* BUGFIX: Fixed various issues relating HexIntegerField +* BUGFIX: Fixed issues in the admin with custom user models + +v1.1.0 (2014-06-29) +=================== +* BACKWARDS-INCOMPATIBLE: The arguments for device.send_message() have changed. See README.rst for details. +* Added a date_created field to GCMDevice and APNSDevice. This field keeps track of when the Device was created. + This requires a `manage.py migrate`. +* Updated APNS protocol support +* Allow sending empty sounds on APNS +* Several APNS bugfixes +* Fixed BigIntegerField support on PostGIS +* Assorted migrations bugfixes +* Added a test suite + +v1.0.1 (2013-01-16) +=================== +* Migrations have been reset. If you were using migrations pre-1.0 you should upgrade to 1.0 instead and only + upgrade to 1.0.1 when you are ready to reset your migrations. + +v1.0 (2013-01-15) +================= +* Full Python 3 support +* GCM device_id is now a custom field based on BigIntegerField and always unsigned (it should be input as hex) +* Django versions older than 1.5 now require 'six' to be installed +* Drop uniqueness on gcm registration_id due to compatibility issues with MySQL +* Fix some issues with migrations +* Add some basic tests +* Integrate with travis-ci +* Add an AUTHORS file + +v0.9 (2013-12-17) +================= + +* Enable installation with pip +* Add wheel support +* Add full documentation +* Various bug fixes + +v0.8 (2013-03-15) +================= + +* Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d6adcc3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +### Coding style + +Please adhere to the coding style throughout the project. + +1. Always use tabs. [Here](https://leclan.ch/tabs) is a short explanation why tabs are preferred. +2. Always use double quotes for strings, unless single quotes avoid unnecessary escapes. +3. When in doubt, [PEP8](https://www.python.org/dev/peps/pep-0008/). Follow its naming conventions. +4. Know when to make exceptions. + +Also see: [How to name things in programming](http://www.slideshare.net/pirhilton/how-to-name-things-the-hardest-problem-in-programming) + + +### Commits and Pull Requests + +Keep the commit log as healthy as the code. It is one of the first places new contributors will look at the project. + +1. No more than one change per commit. There should be no changes in a commit which are unrelated to its message. +2. Every commit should pass all tests on its own. +3. Follow [these conventions](http://chris.beams.io/posts/git-commit/) when writing the commit message + +When filing a Pull Request, make sure it is rebased on top of most recent master. +If you need to modify it or amend it in some way, you should always appropriately [fixup](https://help.github.com/articles/about-git-rebase/) the issues in git and force-push your changes to your fork. + +Also see: [Github Help: Using Pull Requests](https://help.github.com/articles/using-pull-requests/) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9733e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) Jerome Leclanche + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fa3ae80 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include MANIFEST.in +include README.rst +include LICENSE diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..1e6ca7c --- /dev/null +++ b/README.rst @@ -0,0 +1,219 @@ +django-push-notifications +========================= +.. image:: https://api.travis-ci.org/jleclanche/django-push-notifications.png + :target: https://travis-ci.org/jleclanche/django-push-notifications + +A minimal Django app that implements Device models that can send messages through APNS and GCM. + +The app implements two models: ``GCMDevice`` and ``APNSDevice``. Those models share the same attributes: + - ``name`` (optional): A name for the device. + - ``active`` (default True): A boolean that determines whether the device will be sent notifications. + - ``user`` (optional): A foreign key to auth.User, if you wish to link the device to a specific user. + - ``device_id`` (optional): A UUID for the device obtained from Android/iOS APIs, if you wish to uniquely identify it. + - ``registration_id`` (required): The GCM registration id or the APNS token for the device. + + +The app also implements an admin panel, through which you can test single and bulk notifications. Select one or more +GCM or APNS devices and in the action dropdown, select "Send test message" or "Send test message in bulk", accordingly. +Note that sending a non-bulk test message to more than one device will just iterate over the devices and send multiple +single messages. + +Dependencies +------------ +Django 1.8 is required. Support for older versions is available in the release 1.2.1. + +Tastypie support should work on Tastypie 0.11.0 and newer. + +Django REST Framework support should work on DRF version 3.0 and newer. + +Setup +----- +You can install the library directly from pypi using pip: + +.. code-block:: shell + + $ pip install django-push-notifications + + +Edit your settings.py file: + +.. code-block:: python + + INSTALLED_APPS = ( + ... + "push_notifications" + ) + + PUSH_NOTIFICATIONS_SETTINGS = { + "GCM_API_KEY": "", + "APNS_CERTIFICATE": "/path/to/your/certificate.pem", + } + +.. note:: + If you are planning on running your project with ``DEBUG=True``, then make sure you have set the + *development* certificate as your ``APNS_CERTIFICATE``. Otherwise the app will not be able to connect to the correct host. See settings_ for details. + +You can learn more about APNS certificates `here `_. + +Native Django migrations are in use. ``manage.py migrate`` will install and migrate all models. + +.. _settings: + +Settings list +------------- +All settings are contained in a ``PUSH_NOTIFICATIONS_SETTINGS`` dict. + +In order to use GCM, you are required to include ``GCM_API_KEY``. +For APNS, you are required to include ``APNS_CERTIFICATE``. + +- ``APNS_CERTIFICATE``: Absolute path to your APNS certificate file. Certificates with passphrases are not supported. +- ``APNS_CA_CERTIFICATES``: Absolute path to a CA certificates file for APNS. Optional - do not set if not needed. Defaults to None. +- ``GCM_API_KEY``: Your API key for GCM. +- ``APNS_HOST``: The hostname used for the APNS sockets. + - When ``DEBUG=True``, this defaults to ``gateway.sandbox.push.apple.com``. + - When ``DEBUG=False``, this defaults to ``gateway.push.apple.com``. +- ``APNS_PORT``: The port used along with APNS_HOST. Defaults to 2195. +- ``GCM_POST_URL``: The full url that GCM notifications will be POSTed to. Defaults to https://android.googleapis.com/gcm/send. +- ``GCM_MAX_RECIPIENTS``: The maximum amount of recipients that can be contained per bulk message. If the ``registration_ids`` list is larger than that number, multiple bulk messages will be sent. Defaults to 1000 (the maximum amount supported by GCM). + +Sending messages +---------------- +GCM and APNS services have slightly different semantics. The app tries to offer a common interface for both when using the models. + +.. code-block:: python + + from push_notifications.models import APNSDevice, GCMDevice + + device = GCMDevice.objects.get(registration_id=gcm_reg_id) + # The first argument will be sent as "message" to the intent extras Bundle + # Retrieve it with intent.getExtras().getString("message") + device.send_message("You've got mail") + # If you want to customize, send an extra dict and a None message. + # the extras dict will be mapped into the intent extras Bundle. + # For dicts where all values are keys this will be sent as url parameters, + # but for more complex nested collections the extras dict will be sent via + # the bulk message api. + device.send_message(None, extra={"foo": "bar"}) + + device = APNSDevice.objects.get(registration_id=apns_token) + device.send_message("You've got mail") # Alert message may only be sent as text. + device.send_message(None, badge=5) # No alerts but with badge. + device.send_message(None, badge=1, extra={"foo": "bar"}) # Silent message with badge and added custom data. + +.. note:: + APNS does not support sending payloads that exceed 2048 bytes (increased from 256 in 2014). + The message is only one part of the payload, if + once constructed the payload exceeds the maximum size, an ``APNSDataOverflow`` exception will be raised before anything is sent. + +Sending messages in bulk +------------------------ +.. code-block:: python + + from push_notifications.models import APNSDevice, GCMDevice + + devices = GCMDevice.objects.filter(user__first_name="James") + devices.send_message("Happy name day!") + +Sending messages in bulk makes use of the bulk mechanics offered by GCM and APNS. It is almost always preferable to send +bulk notifications instead of single ones. + +Administration +-------------- + +APNS devices which are not receiving push notifications can be set to inactive by two methods. The web admin interface for +APNS devices has a "prune devices" option. Any selected devices which are not receiving notifications will be set to inactive [1]_. +There is also a management command to prune all devices failing to receive notifications: + +.. code-block:: shell + + $ python manage.py prune_devices + +This removes all devices which are not receiving notifications. + +For more information, please refer to the APNS feedback service_. + +.. _service: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html + +Exceptions +---------- + +- ``NotificationError(Exception)``: Base exception for all notification-related errors. +- ``gcm.GCMError(NotificationError)``: An error was returned by GCM. This is never raised when using bulk notifications. +- ``apns.APNSError(NotificationError)``: Something went wrong upon sending APNS notifications. +- ``apns.APNSDataOverflow(APNSError)``: The APNS payload exceeds its maximum size and cannot be sent. + +Tastypie support +---------------- + +The app includes tastypie-compatible resources in push_notifications.api.tastypie. These can be used as-is, or as base classes +for more involved APIs. +The following resources are available: + +- ``APNSDeviceResource`` +- ``GCMDeviceResource`` +- ``APNSDeviceAuthenticatedResource`` +- ``GCMDeviceAuthenticatedResource`` + +The base device resources will not ask for authentication, while the authenticated ones will link the logged in user to +the device they register. +Subclassing the authenticated resources in order to add a ``SameUserAuthentication`` and a user ``ForeignKey`` is recommended. + +When registered, the APIs will show up at ``/device/apns`` and ``/device/gcm``, respectively. + +Django REST Framework (DRF) support +----------------------------------- + +ViewSets are available for both APNS and GCM devices in two permission flavors: + +- ``APNSDeviceViewSet`` and ``GCMDeviceViewSet`` + + - Permissions as specified in settings (``AllowAny`` by default, which is not recommended) + - A device may be registered without associating it with a user + +- ``APNSDeviceAuthorizedViewSet`` and ``GCMDeviceAuthorizedViewSet`` + + - Permissions are ``IsAuthenticated`` and custom permission ``IsOwner``, which will only allow the ``request.user`` to get and update devices that belong to that user + - Requires a user to be authenticated, so all devices will be associated with a user + +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. + +Routes can be added one of two ways: + +- Routers_ (include all views) +.. _Routers: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#using-routers + +:: + + from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet + from rest_framework.routers import DefaultRouter + + router = DefaultRouter() + router.register(r'device/apns', APNSDeviceAuthorizedViewSet) + router.register(r'device/gcm', GCMDeviceAuthorizedViewSet) + + urlpatterns = patterns('', + # URLs will show up at /device/apns + url(r'^', include(router.urls)), + # ... + ) + +- Using as_view_ (specify which views to include) +.. _as_view: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#binding-viewsets-to-urls-explicitly + +:: + + from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet + + urlpatterns = patterns('', + # Only allow creation of devices by authenticated users + url(r'^device/apns/?$', APNSDeviceAuthorizedViewSet.as_view({'post': 'create'}), name='create_apns_device'), + # ... + ) + + +Python 3 support +---------------- + +``django-push-notifications`` is fully compatible with Python 3.4 & 3.5 + +.. [1] Any devices which are not selected, but are not receiving notifications will not be deactivated on a subsequent call to "prune devices" unless another attempt to send a message to the device fails after the call to the feedback service. diff --git a/push_notifications/__init__.py b/push_notifications/__init__.py new file mode 100644 index 0000000..22eb89f --- /dev/null +++ b/push_notifications/__init__.py @@ -0,0 +1,8 @@ + +__author__ = "Jerome Leclanche" +__email__ = "jerome@leclan.ch" +__version__ = "1.4.1" + + +class NotificationError(Exception): + pass diff --git a/push_notifications/admin.py b/push_notifications/admin.py new file mode 100644 index 0000000..fd4666b --- /dev/null +++ b/push_notifications/admin.py @@ -0,0 +1,81 @@ +from django.contrib import admin, messages +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _ +from .gcm import GCMError +from .models import APNSDevice, GCMDevice, get_expired_tokens + + +User = get_user_model() + + +class DeviceAdmin(admin.ModelAdmin): + list_display = ("__str__", "device_id", "user", "active", "date_created") + search_fields = ("name", "device_id", "user__%s" % (User.USERNAME_FIELD)) + list_filter = ("active", ) + actions = ("send_message", "send_bulk_message", "prune_devices", "enable", "disable") + + def send_messages(self, request, queryset, bulk=False): + """ + Provides error handling for DeviceAdmin send_message and send_bulk_message methods. + """ + ret = [] + errors = [] + r = "" + + for device in queryset: + try: + if bulk: + r = queryset.send_message("Test bulk notification") + else: + r = device.send_message("Test single notification") + if r: + ret.append(r) + except GCMError as e: + errors.append(str(e)) + + if bulk: + break + + if errors: + self.message_user(request, _("Some messages could not be processed: %r" % (", ".join(errors))), level=messages.ERROR) + if ret: + if not bulk: + ret = ", ".join(ret) + if errors: + msg = _("Some messages were sent: %s" % (ret)) + else: + msg = _("All messages were sent: %s" % (ret)) + self.message_user(request, msg) + + def send_message(self, request, queryset): + self.send_messages(request, queryset) + send_message.short_description = _("Send test message") + + def send_bulk_message(self, request, queryset): + self.send_messages(request, queryset, True) + send_bulk_message.short_description = _("Send test message in bulk") + + def enable(self, request, queryset): + queryset.update(active=True) + enable.short_description = _("Enable selected devices") + + def disable(self, request, queryset): + queryset.update(active=False) + disable.short_description = _("Disable selected devices") + + def prune_devices(self, request, queryset): + # Note that when get_expired_tokens() is called, Apple's + # feedback service resets, so, calling it again won't return + # the device again (unless a message is sent to it again). So, + # if the user doesn't select all the devices for pruning, we + # could very easily leave an expired device as active. Maybe + # this is just a bad API. + expired = get_expired_tokens() + devices = queryset.filter(registration_id__in=expired) + for d in devices: + d.active = False + d.save() + + +admin.site.register(APNSDevice, DeviceAdmin) +admin.site.register(GCMDevice, DeviceAdmin) diff --git a/push_notifications/api/__init__.py b/push_notifications/api/__init__.py new file mode 100644 index 0000000..1f8fbd1 --- /dev/null +++ b/push_notifications/api/__init__.py @@ -0,0 +1,12 @@ +from django.conf import settings + +if "tastypie" in settings.INSTALLED_APPS: + # Tastypie resources are importable from the api package level (backwards compatibility) + from .tastypie import APNSDeviceResource, GCMDeviceResource, APNSDeviceAuthenticatedResource, GCMDeviceAuthenticatedResource + + __all__ = [ + "APNSDeviceResource", + "GCMDeviceResource", + "APNSDeviceAuthenticatedResource", + "GCMDeviceAuthenticatedResource" + ] diff --git a/push_notifications/api/rest_framework.py b/push_notifications/api/rest_framework.py new file mode 100644 index 0000000..7f0b826 --- /dev/null +++ b/push_notifications/api/rest_framework.py @@ -0,0 +1,127 @@ +from __future__ import absolute_import + +from rest_framework import permissions +from rest_framework.serializers import ModelSerializer, ValidationError +from rest_framework.validators import UniqueValidator +from rest_framework.viewsets import ModelViewSet +from rest_framework.fields import IntegerField + +from push_notifications.models import APNSDevice, GCMDevice +from push_notifications.fields import hex_re +from push_notifications.fields import UNSIGNED_64BIT_INT_MAX_VALUE + +# Fields + + +class HexIntegerField(IntegerField): + """ + Store an integer represented as a hex string of form "0x01". + """ + + def to_internal_value(self, data): + # validate hex string and convert it to the unsigned + # integer representation for internal use + try: + data = int(data, 16) + except ValueError: + raise ValidationError("Device ID is not a valid hex number") + return super(HexIntegerField, self).to_internal_value(data) + + def to_representation(self, value): + return value + + +# Serializers +class DeviceSerializerMixin(ModelSerializer): + class Meta: + fields = ("name", "registration_id", "device_id", "active", "date_created") + read_only_fields = ("date_created", ) + + # See https://github.com/tomchristie/django-rest-framework/issues/1101 + extra_kwargs = {"active": {"default": True}} + + +class APNSDeviceSerializer(ModelSerializer): + + class Meta(DeviceSerializerMixin.Meta): + model = APNSDevice + + def validate_registration_id(self, value): + # iOS device tokens are 256-bit hexadecimal (64 characters). In 2016 Apple is increasing + # iOS device tokens to 100 bytes hexadecimal (200 characters). + + if hex_re.match(value) is None or len(value) not in (64, 200): + raise ValidationError("Registration ID (device token) is invalid") + + return value + + +class GCMDeviceSerializer(ModelSerializer): + device_id = HexIntegerField( + help_text="ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)", + style={'input_type': 'text'}, + required=False + ) + + class Meta(DeviceSerializerMixin.Meta): + model = GCMDevice + + extra_kwargs = { + # Work around an issue with validating the uniqueness of + # registration ids of up to 4k + 'registration_id': { + 'validators': [ + UniqueValidator(queryset=GCMDevice.objects.all()) + ] + } + } + + def validate_device_id(self, value): + # device ids are 64 bit unsigned values + if value > UNSIGNED_64BIT_INT_MAX_VALUE: + raise ValidationError("Device ID is out of range") + return value + + +# Permissions +class IsOwner(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + # must be the owner to view the object + return obj.user == request.user + + +# Mixins +class DeviceViewSetMixin(object): + lookup_field = "registration_id" + + def perform_create(self, serializer): + if self.request.user.is_authenticated(): + serializer.save(user=self.request.user) + return super(DeviceViewSetMixin, self).perform_create(serializer) + + +class AuthorizedMixin(object): + permission_classes = (permissions.IsAuthenticated, IsOwner) + + def get_queryset(self): + # filter all devices to only those belonging to the current user + return self.queryset.filter(user=self.request.user) + + +# ViewSets +class APNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): + queryset = APNSDevice.objects.all() + serializer_class = APNSDeviceSerializer + + +class APNSDeviceAuthorizedViewSet(AuthorizedMixin, APNSDeviceViewSet): + pass + + +class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet): + queryset = GCMDevice.objects.all() + serializer_class = GCMDeviceSerializer + + +class GCMDeviceAuthorizedViewSet(AuthorizedMixin, GCMDeviceViewSet): + pass diff --git a/push_notifications/api/tastypie.py b/push_notifications/api/tastypie.py new file mode 100644 index 0000000..f1c048f --- /dev/null +++ b/push_notifications/api/tastypie.py @@ -0,0 +1,42 @@ +from tastypie.authorization import Authorization +from tastypie.authentication import BasicAuthentication +from tastypie.resources import ModelResource +from push_notifications.models import APNSDevice, GCMDevice + + +class APNSDeviceResource(ModelResource): + class Meta: + authorization = Authorization() + queryset = APNSDevice.objects.all() + resource_name = "device/apns" + + +class GCMDeviceResource(ModelResource): + class Meta: + authorization = Authorization() + queryset = GCMDevice.objects.all() + resource_name = "device/gcm" + + +class APNSDeviceAuthenticatedResource(APNSDeviceResource): + # user = ForeignKey(UserResource, "user") + + class Meta(APNSDeviceResource.Meta): + authentication = BasicAuthentication() + # authorization = SameUserAuthorization() + + def obj_create(self, bundle, **kwargs): + # See https://github.com/toastdriven/django-tastypie/issues/854 + return super(APNSDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs) + + +class GCMDeviceAuthenticatedResource(GCMDeviceResource): + # user = ForeignKey(UserResource, "user") + + class Meta(GCMDeviceResource.Meta): + authentication = BasicAuthentication() + # authorization = SameUserAuthorization() + + def obj_create(self, bundle, **kwargs): + # See https://github.com/toastdriven/django-tastypie/issues/854 + return super(GCMDeviceAuthenticatedResource, self).obj_create(bundle, user=bundle.request.user, **kwargs) diff --git a/push_notifications/apns.py b/push_notifications/apns.py new file mode 100644 index 0000000..00a6879 --- /dev/null +++ b/push_notifications/apns.py @@ -0,0 +1,238 @@ +""" +Apple Push Notification Service +Documentation is available on the iOS Developer Library: +https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html +""" + +import codecs +import json +import ssl +import struct +import socket +import time +from contextlib import closing +from binascii import unhexlify +from django.core.exceptions import ImproperlyConfigured +from . import NotificationError +from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS + + +class APNSError(NotificationError): + pass + + +class APNSServerError(APNSError): + def __init__(self, status, identifier): + super(APNSServerError, self).__init__(status, identifier) + self.status = status + self.identifier = identifier + + +class APNSDataOverflow(APNSError): + pass + + +def _apns_create_socket(address_tuple): + certfile = SETTINGS.get("APNS_CERTIFICATE") + if not certfile: + raise ImproperlyConfigured( + 'You need to set PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] to send messages through APNS.' + ) + + try: + with open(certfile, "r") as f: + f.read() + except Exception as e: + raise ImproperlyConfigured("The APNS certificate file at %r is not readable: %s" % (certfile, e)) + + ca_certs = SETTINGS.get("APNS_CA_CERTIFICATES") + + sock = socket.socket() + sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, certfile=certfile, ca_certs=ca_certs) + sock.connect(address_tuple) + + return sock + + +def _apns_create_socket_to_push(): + return _apns_create_socket((SETTINGS["APNS_HOST"], SETTINGS["APNS_PORT"])) + + +def _apns_create_socket_to_feedback(): + return _apns_create_socket((SETTINGS["APNS_FEEDBACK_HOST"], SETTINGS["APNS_FEEDBACK_PORT"])) + + +def _apns_pack_frame(token_hex, payload, identifier, expiration, priority): + token = unhexlify(token_hex) + # |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expiration:4}|{priority:1} + frame_len = 3 * 5 + len(token) + len(payload) + 4 + 4 + 1 # 5 items, each 3 bytes prefix, then each item length + frame_fmt = "!BIBH%ssBH%ssBHIBHIBHB" % (len(token), len(payload)) + frame = struct.pack( + frame_fmt, + 2, frame_len, + 1, len(token), token, + 2, len(payload), payload, + 3, 4, identifier, + 4, 4, expiration, + 5, 1, priority) + + return frame + + +def _apns_check_errors(sock): + timeout = SETTINGS["APNS_ERROR_TIMEOUT"] + if timeout is None: + return # assume everything went fine! + saved_timeout = sock.gettimeout() + try: + sock.settimeout(timeout) + data = sock.recv(6) + if data: + command, status, identifier = struct.unpack("!BBI", data) + # apple protocol says command is always 8. See http://goo.gl/ENUjXg + assert command == 8, "Command must be 8!" + if status != 0: + raise APNSServerError(status, identifier) + except socket.timeout: # py3, see http://bugs.python.org/issue10272 + pass + except ssl.SSLError as e: # py2 + if "timed out" not in e.message: + raise + finally: + sock.settimeout(saved_timeout) + + +def _apns_send(token, alert, badge=None, sound=None, category=None, content_available=False, + action_loc_key=None, loc_key=None, loc_args=[], extra={}, identifier=0, + expiration=None, priority=10, socket=None): + data = {} + aps_data = {} + + if action_loc_key or loc_key or loc_args: + alert = {"body": alert} if alert else {} + if action_loc_key: + alert["action-loc-key"] = action_loc_key + if loc_key: + alert["loc-key"] = loc_key + if loc_args: + alert["loc-args"] = loc_args + + if alert is not None: + aps_data["alert"] = alert + + if badge is not None: + aps_data["badge"] = badge + + if sound is not None: + aps_data["sound"] = sound + + if category is not None: + aps_data["category"] = category + + if content_available: + aps_data["content-available"] = 1 + + data["aps"] = aps_data + data.update(extra) + + # convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests) + json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") + + max_size = SETTINGS["APNS_MAX_NOTIFICATION_SIZE"] + if len(json_data) > max_size: + raise APNSDataOverflow("Notification body cannot exceed %i bytes" % (max_size)) + + # if expiration isn't specified use 1 month from now + expiration_time = expiration if expiration is not None else int(time.time()) + 2592000 + + frame = _apns_pack_frame(token, json_data, identifier, expiration_time, priority) + + if socket: + socket.write(frame) + else: + with closing(_apns_create_socket_to_push()) as socket: + socket.write(frame) + _apns_check_errors(socket) + + +def _apns_read_and_unpack(socket, data_format): + length = struct.calcsize(data_format) + data = socket.recv(length) + if data: + return struct.unpack_from(data_format, data, 0) + else: + return None + + +def _apns_receive_feedback(socket): + expired_token_list = [] + + # read a timestamp (4 bytes) and device token length (2 bytes) + header_format = '!LH' + has_data = True + while has_data: + try: + # read the header tuple + header_data = _apns_read_and_unpack(socket, header_format) + if header_data is not None: + timestamp, token_length = header_data + # Unpack format for a single value of length bytes + token_format = '%ss' % token_length + device_token = _apns_read_and_unpack(socket, token_format) + if device_token is not None: + # _apns_read_and_unpack returns a tuple, but + # it's just one item, so get the first. + expired_token_list.append((timestamp, device_token[0])) + else: + has_data = False + except socket.timeout: # py3, see http://bugs.python.org/issue10272 + pass + except ssl.SSLError as e: # py2 + if "timed out" not in e.message: + raise + + return expired_token_list + + +def apns_send_message(registration_id, alert, **kwargs): + """ + Sends an APNS notification to a single registration_id. + This will send the notification as form data. + If sending multiple notifications, it is more efficient to use + apns_send_bulk_message() + + Note that if set alert should always be a string. If it is not set, + it won't be included in the notification. You will need to pass None + to this for silent notifications. + """ + + _apns_send(registration_id, alert, **kwargs) + + +def apns_send_bulk_message(registration_ids, alert, **kwargs): + """ + Sends an APNS notification to one or more registration_ids. + The registration_ids argument needs to be a list. + + Note that if set alert should always be a string. If it is not set, + it won't be included in the notification. You will need to pass None + to this for silent notifications. + """ + with closing(_apns_create_socket_to_push()) as socket: + for identifier, registration_id in enumerate(registration_ids): + _apns_send(registration_id, alert, identifier=identifier, socket=socket, **kwargs) + _apns_check_errors(socket) + + +def apns_fetch_inactive_ids(): + """ + Queries the APNS server for id's that are no longer active since + the last fetch + """ + with closing(_apns_create_socket_to_feedback()) as socket: + inactive_ids = [] + # Maybe we should have a flag to return the timestamp? + # It doesn't seem that useful right now, though. + for tStamp, registration_id in _apns_receive_feedback(socket): + inactive_ids.append(codecs.encode(registration_id, 'hex_codec')) + return inactive_ids diff --git a/push_notifications/fields.py b/push_notifications/fields.py new file mode 100644 index 0000000..e177818 --- /dev/null +++ b/push_notifications/fields.py @@ -0,0 +1,123 @@ +import re +import struct +from django import forms +from django.core.validators import MaxValueValidator +from django.core.validators import MinValueValidator +from django.core.validators import RegexValidator +from django.db import models, connection +from django.utils import six +from django.utils.translation import ugettext_lazy as _ + +UNSIGNED_64BIT_INT_MIN_VALUE = 0 +UNSIGNED_64BIT_INT_MAX_VALUE = 2 ** 64 - 1 + +__all__ = ["HexadecimalField", "HexIntegerField"] + + +hex_re = re.compile(r"^(([0-9A-f])|(0x[0-9A-f]))+$") +signed_integer_engines = [ + "django.db.backends.postgresql_psycopg2", + "django.contrib.gis.db.backends.postgis", + "django.db.backends.sqlite3" +] + + +def _using_signed_storage(): + return connection.settings_dict["ENGINE"] in signed_integer_engines + + +def _signed_to_unsigned_integer(value): + return struct.unpack("Q", struct.pack("q", value))[0] + + +def _unsigned_to_signed_integer(value): + return struct.unpack("q", struct.pack("Q", value))[0] + + +def _hex_string_to_unsigned_integer(value): + return int(value, 16) + + +def _unsigned_integer_to_hex_string(value): + return hex(value).rstrip("L") + + +class HexadecimalField(forms.CharField): + """ + A form field that accepts only hexadecimal numbers + """ + def __init__(self, *args, **kwargs): + self.default_validators = [RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid")] + super(HexadecimalField, self).__init__(*args, **kwargs) + + def prepare_value(self, value): + # converts bigint from db to hex before it is displayed in admin + if value and not isinstance(value, six.string_types) \ + and connection.vendor in ("mysql", "sqlite"): + value = _unsigned_integer_to_hex_string(value) + return super(forms.CharField, self).prepare_value(value) + + +class HexIntegerField(models.BigIntegerField): + """ + This field stores a hexadecimal *string* of up to 64 bits as an unsigned integer + on *all* backends including postgres. + + Reasoning: Postgres only supports signed bigints. Since we don't care about + signedness, we store it as signed, and cast it to unsigned when we deal with + the actual value (with struct) + + On sqlite and mysql, native unsigned bigint types are used. In all cases, the + value we deal with in python is always in hex. + """ + + validators = [ + MinValueValidator(UNSIGNED_64BIT_INT_MIN_VALUE), + MaxValueValidator(UNSIGNED_64BIT_INT_MAX_VALUE) + ] + + def db_type(self, connection): + engine = connection.settings_dict["ENGINE"] + if "mysql" in engine: + return "bigint unsigned" + elif "sqlite" in engine: + return "UNSIGNED BIG INT" + else: + return super(HexIntegerField, self).db_type(connection=connection) + + def get_prep_value(self, value): + """ Return the integer value to be stored from the hex string """ + if value is None or value == "": + return None + if isinstance(value, six.string_types): + value = _hex_string_to_unsigned_integer(value) + if _using_signed_storage(): + value = _unsigned_to_signed_integer(value) + return value + + def from_db_value(self, value, expression, connection, context): + """ Return an unsigned int representation from all db backends """ + if value is None: + return value + if _using_signed_storage(): + value = _signed_to_unsigned_integer(value) + return value + + def to_python(self, value): + """ Return a str representation of the hexadecimal """ + if isinstance(value, six.string_types): + return value + if value is None: + return value + return _unsigned_integer_to_hex_string(value) + + def formfield(self, **kwargs): + defaults = {"form_class": HexadecimalField} + defaults.update(kwargs) + # yes, that super call is right + return super(models.IntegerField, self).formfield(**defaults) + + def run_validators(self, value): + # make sure validation is performed on integer value not string value + value = _hex_string_to_unsigned_integer(value) + return super(models.BigIntegerField, self).run_validators(value) diff --git a/push_notifications/gcm.py b/push_notifications/gcm.py new file mode 100644 index 0000000..dbfc215 --- /dev/null +++ b/push_notifications/gcm.py @@ -0,0 +1,194 @@ +""" +Google Cloud Messaging +Previously known as C2DM +Documentation is available on the Android Developer website: +https://developer.android.com/google/gcm/index.html +""" + +import json +from .models import GCMDevice + + +try: + from urllib.request import Request, urlopen + from urllib.parse import urlencode +except ImportError: + # Python 2 support + from urllib2 import Request, urlopen + from urllib import urlencode + +from django.core.exceptions import ImproperlyConfigured +from . import NotificationError +from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS + + +class GCMError(NotificationError): + pass + + +def _chunks(l, n): + """ + Yield successive chunks from list \a l with a minimum size \a n + """ + for i in range(0, len(l), n): + yield l[i:i + n] + + +def _gcm_send(data, content_type): + key = SETTINGS.get("GCM_API_KEY") + if not key: + raise ImproperlyConfigured('You need to set PUSH_NOTIFICATIONS_SETTINGS["GCM_API_KEY"] to send messages through GCM.') + + headers = { + "Content-Type": content_type, + "Authorization": "key=%s" % (key), + "Content-Length": str(len(data)), + } + + request = Request(SETTINGS["GCM_POST_URL"], data, headers) + return urlopen(request).read().decode("utf-8") + + +def _gcm_send_plain(registration_id, data, **kwargs): + """ + Sends a GCM notification to a single registration_id. + This will send the notification as form data. + If sending multiple notifications, it is more efficient to use + gcm_send_bulk_message() with a list of registration_ids + """ + + values = {"registration_id": registration_id} + + for k, v in data.items(): + values["data.%s" % (k)] = v.encode("utf-8") + + for k, v in kwargs.items(): + if v: + if isinstance(v, bool): + # Encode bools into ints + v = 1 + values[k] = v + + data = urlencode(sorted(values.items())).encode("utf-8") # sorted items for tests + + result = _gcm_send(data, "application/x-www-form-urlencoded;charset=UTF-8") + + # Information about handling response from Google docs (https://developers.google.com/cloud-messaging/http): + # If first line starts with id, check second line: + # If second line starts with registration_id, gets its value and replace the registration tokens in your + # server database. Otherwise, get the value of Error + + if result.startswith("id"): + lines = result.split("\n") + if len(lines) > 1 and lines[1].startswith("registration_id"): + new_id = lines[1].split("=")[-1] + _gcm_handle_canonical_id(new_id, registration_id) + + elif result.startswith("Error="): + if result in ("Error=NotRegistered", "Error=InvalidRegistration"): + # Deactivate the problematic device + device = GCMDevice.objects.filter(registration_id=values["registration_id"]) + device.update(active=0) + return result + + raise GCMError(result) + + return result + + +def _gcm_send_json(registration_ids, data, **kwargs): + """ + Sends a GCM notification to one or more registration_ids. The registration_ids + needs to be a list. + This will send the notification as json data. + """ + + values = {"registration_ids": registration_ids} + + if data is not None: + values["data"] = data + + for k, v in kwargs.items(): + if v: + values[k] = v + + data = json.dumps(values, separators=(",", ":"), sort_keys=True).encode("utf-8") # keys sorted for tests + + response = json.loads(_gcm_send(data, "application/json")) + if response["failure"] or response["canonical_ids"]: + ids_to_remove, old_new_ids = [], [] + throw_error = False + for index, result in enumerate(response["results"]): + error = result.get("error") + if error: + # Information from Google docs (https://developers.google.com/cloud-messaging/http) + # If error is NotRegistered or InvalidRegistration, then we will deactivate devices because this + # registration ID is no more valid and can't be used to send messages, otherwise raise error + if error in ("NotRegistered", "InvalidRegistration"): + ids_to_remove.append(registration_ids[index]) + else: + throw_error = True + + # If registration_id is set, replace the original ID with the new value (canonical ID) in your + # server database. Note that the original ID is not part of the result, so you need to obtain it + # from the list of registration_ids passed in the request (using the same index). + new_id = result.get("registration_id") + if new_id: + old_new_ids.append((registration_ids[index], new_id)) + + if ids_to_remove: + removed = GCMDevice.objects.filter(registration_id__in=ids_to_remove) + removed.update(active=0) + + for old_id, new_id in old_new_ids: + _gcm_handle_canonical_id(new_id, old_id) + + if throw_error: + raise GCMError(response) + return response + + +def _gcm_handle_canonical_id(canonical_id, current_id): + """ + Handle situation when GCM server response contains canonical ID + """ + if GCMDevice.objects.filter(registration_id=canonical_id, active=True).exists(): + GCMDevice.objects.filter(registration_id=current_id).update(active=False) + else: + GCMDevice.objects.filter(registration_id=current_id).update(registration_id=canonical_id) + + +def gcm_send_message(registration_id, data, **kwargs): + """ + Sends a GCM notification to a single registration_id. + + If sending multiple notifications, it is more efficient to use + gcm_send_bulk_message() with a list of registration_ids + + A reference of extra keyword arguments sent to the server is available here: + https://developers.google.com/cloud-messaging/server-ref#downstream + """ + + return _gcm_send_plain(registration_id, data, **kwargs) + + +def gcm_send_bulk_message(registration_ids, data, **kwargs): + """ + Sends a GCM notification to one or more registration_ids. The registration_ids + needs to be a list. + This will send the notification as json data. + + A reference of extra keyword arguments sent to the server is available here: + https://developers.google.com/cloud-messaging/server-ref#downstream + """ + + # GCM only allows up to 1000 reg ids per bulk message + # https://developer.android.com/google/gcm/gcm.html#request + max_recipients = SETTINGS.get("GCM_MAX_RECIPIENTS") + if len(registration_ids) > max_recipients: + ret = [] + for chunk in _chunks(registration_ids, max_recipients): + ret.append(_gcm_send_json(chunk, data, **kwargs)) + return ret + + return _gcm_send_json(registration_ids, data, **kwargs) diff --git a/push_notifications/management/__init__.py b/push_notifications/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/push_notifications/management/commands/__init__.py b/push_notifications/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/push_notifications/management/commands/prune_devices.py b/push_notifications/management/commands/prune_devices.py new file mode 100644 index 0000000..99913c6 --- /dev/null +++ b/push_notifications/management/commands/prune_devices.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + can_import_settings = True + help = 'Deactivate APNS devices that are not receiving notifications' + + def handle(self, *args, **options): + from push_notifications.models import APNSDevice, get_expired_tokens + expired = get_expired_tokens() + devices = APNSDevice.objects.filter(registration_id__in=expired) + for d in devices: + self.stdout.write('deactivating [%s]' % d.registration_id) + d.active = False + d.save() + self.stdout.write('deactivated %d devices' % len(devices)) diff --git a/push_notifications/migrations/0001_initial.py b/push_notifications/migrations/0001_initial.py new file mode 100644 index 0000000..3f7af4d --- /dev/null +++ b/push_notifications/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import push_notifications.fields +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='APNSDevice', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), + ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), + ('device_id', models.UUIDField(help_text='UDID / UIDevice.identifierForVendor()', max_length=32, null=True, verbose_name='Device ID', blank=True, db_index=True)), + ('registration_id', models.CharField(unique=True, max_length=64, verbose_name='Registration ID')), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'verbose_name': 'APNS device', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='GCMDevice', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), + ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), + ('device_id', push_notifications.fields.HexIntegerField(help_text='ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)', null=True, verbose_name='Device ID', blank=True, db_index=True)), + ('registration_id', models.TextField(verbose_name='Registration ID')), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ], + options={ + 'verbose_name': 'GCM device', + }, + bases=(models.Model,), + ), + ] diff --git a/push_notifications/migrations/0002_auto_20160106_0850.py b/push_notifications/migrations/0002_auto_20160106_0850.py new file mode 100644 index 0000000..d7b9d75 --- /dev/null +++ b/push_notifications/migrations/0002_auto_20160106_0850.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-01-06 08:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('push_notifications', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='apnsdevice', + name='registration_id', + field=models.CharField(max_length=200, unique=True, verbose_name='Registration ID'), + ), + ] diff --git a/push_notifications/migrations/__init__.py b/push_notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/push_notifications/models.py b/push_notifications/models.py new file mode 100644 index 0000000..4a6bdfd --- /dev/null +++ b/push_notifications/models.py @@ -0,0 +1,100 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from .fields import HexIntegerField + + +@python_2_unicode_compatible +class Device(models.Model): + name = models.CharField(max_length=255, verbose_name=_("Name"), blank=True, null=True) + active = models.BooleanField(verbose_name=_("Is active"), default=True, + help_text=_("Inactive devices will not be sent notifications")) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) + date_created = models.DateTimeField(verbose_name=_("Creation date"), auto_now_add=True, null=True) + + class Meta: + abstract = True + + def __str__(self): + return self.name or \ + str(self.device_id or "") or \ + "%s for %s" % (self.__class__.__name__, self.user or "unknown user") + + +class GCMDeviceManager(models.Manager): + def get_queryset(self): + return GCMDeviceQuerySet(self.model) + + +class GCMDeviceQuerySet(models.query.QuerySet): + def send_message(self, message, **kwargs): + if self: + from .gcm import gcm_send_bulk_message + + data = kwargs.pop("extra", {}) + if message is not None: + data["message"] = message + + reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True)) + return gcm_send_bulk_message(registration_ids=reg_ids, data=data, **kwargs) + + +class GCMDevice(Device): + # device_id cannot be a reliable primary key as fragmentation between different devices + # can make it turn out to be null and such: + # http://android-developers.blogspot.co.uk/2011/03/identifying-app-installations.html + device_id = HexIntegerField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True, + help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)")) + registration_id = models.TextField(verbose_name=_("Registration ID")) + + objects = GCMDeviceManager() + + class Meta: + verbose_name = _("GCM device") + + def send_message(self, message, **kwargs): + from .gcm import gcm_send_message + data = kwargs.pop("extra", {}) + if message is not None: + data["message"] = message + return gcm_send_message(registration_id=self.registration_id, data=data, **kwargs) + + +class APNSDeviceManager(models.Manager): + def get_queryset(self): + return APNSDeviceQuerySet(self.model) + + +class APNSDeviceQuerySet(models.query.QuerySet): + def send_message(self, message, **kwargs): + if self: + from .apns import apns_send_bulk_message + reg_ids = list(self.filter(active=True).values_list('registration_id', flat=True)) + return apns_send_bulk_message(registration_ids=reg_ids, alert=message, **kwargs) + + +class APNSDevice(Device): + device_id = models.UUIDField(verbose_name=_("Device ID"), blank=True, null=True, db_index=True, + help_text="UDID / UIDevice.identifierForVendor()") + registration_id = models.CharField(verbose_name=_("Registration ID"), max_length=200, unique=True) + + objects = APNSDeviceManager() + + class Meta: + verbose_name = _("APNS device") + + def send_message(self, message, **kwargs): + from .apns import apns_send_message + + return apns_send_message(registration_id=self.registration_id, alert=message, **kwargs) + + +# This is an APNS-only function right now, but maybe GCM will implement it +# in the future. But the definition of 'expired' may not be the same. Whatevs +def get_expired_tokens(): + from .apns import apns_fetch_inactive_ids + return apns_fetch_inactive_ids() diff --git a/push_notifications/settings.py b/push_notifications/settings.py new file mode 100644 index 0000000..19cf72e --- /dev/null +++ b/push_notifications/settings.py @@ -0,0 +1,21 @@ +from django.conf import settings + +PUSH_NOTIFICATIONS_SETTINGS = getattr(settings, "PUSH_NOTIFICATIONS_SETTINGS", {}) + + +# GCM +PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_POST_URL", "https://android.googleapis.com/gcm/send") +PUSH_NOTIFICATIONS_SETTINGS.setdefault("GCM_MAX_RECIPIENTS", 1000) + + +# APNS +PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_PORT", 2195) +PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_PORT", 2196) +PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_ERROR_TIMEOUT", None) +PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_MAX_NOTIFICATION_SIZE", 2048) +if settings.DEBUG: + PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.sandbox.push.apple.com") + PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.sandbox.push.apple.com") +else: + PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_HOST", "gateway.push.apple.com") + PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_FEEDBACK_HOST", "feedback.push.apple.com") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..94a0e83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5e40900 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..6193df8 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +import os.path +import push_notifications +from distutils.core import setup + +README = open(os.path.join(os.path.dirname(__file__), "README.rst")).read() + +CLASSIFIERS = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Networking", +] + +setup( + name="django-push-notifications", + packages=[ + "push_notifications", + "push_notifications/api", + "push_notifications/migrations", + "push_notifications/management", + "push_notifications/management/commands", + ], + author=push_notifications.__author__, + author_email=push_notifications.__email__, + classifiers=CLASSIFIERS, + description="Send push notifications to mobile devices through GCM or APNS in Django.", + download_url="https://github.com/jleclanche/django-push-notifications/tarball/master", + long_description=README, + url="https://github.com/jleclanche/django-push-notifications", + version=push_notifications.__version__, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..34d2fe9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,12 @@ +from test_models import * +from test_gcm_push_payload import * +from test_apns_push_payload import * +from test_management_commands import * + +# conditionally test rest_framework api if the DRF package is installed +try: + import rest_framework +except ImportError: + pass +else: + from test_rest_framework import * diff --git a/tests/mock_responses.py b/tests/mock_responses.py new file mode 100644 index 0000000..72d8f44 --- /dev/null +++ b/tests/mock_responses.py @@ -0,0 +1,17 @@ +GCM_PLAIN_RESPONSE = 'id=1:08' +GCM_JSON_RESPONSE = '{"multicast_id":108,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"1:08"}]}' +GCM_MULTIPLE_JSON_RESPONSE = ('{"multicast_id":108,"success":2,"failure":0,"canonical_ids":0,"results":' + '[{"message_id":"1:08"}, {"message_id": "1:09"}]}') +GCM_PLAIN_RESPONSE_ERROR = ['Error=NotRegistered', 'Error=InvalidRegistration'] +GCM_PLAIN_RESPONSE_ERROR_B = 'Error=MismatchSenderId' +GCM_PLAIN_CANONICAL_ID_RESPONSE = "id=1:2342\nregistration_id=NEW_REGISTRATION_ID" +GCM_JSON_RESPONSE_ERROR = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, "results":' + ' [{"error": "NotRegistered"}, {"message_id": "0:1433830664381654%3449593ff9fd7ecd"}, ' + '{"error": "InvalidRegistration"}]}') +GCM_JSON_RESPONSE_ERROR_B = ('{"success":1, "failure": 2, "canonical_ids": 0, "cast_id": 6358665107659088804, ' + '"results": [{"error": "MismatchSenderId"}, {"message_id": ' + '"0:1433830664381654%3449593ff9fd7ecd"}, {"error": "InvalidRegistration"}]}') +GCM_DRF_INVALID_HEX_ERROR = {'device_id': [u"Device ID is not a valid hex number"]} +GCM_DRF_OUT_OF_RANGE_ERROR = {'device_id': [u"Device ID is out of range"]} +GCM_JSON_CANONICAL_ID_RESPONSE = '{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,"results":[{"registration_id":"NEW_REGISTRATION_ID","message_id":"0:1440068396670935%6868637df9fd7ecd"},{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}' +GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE = '{"failure":0,"canonical_ids":1,"success":2,"multicast_id":7173139966327257000,"results":[{"registration_id":"bar","message_id":"0:1440068396670935%6868637df9fd7ecd"},{"message_id":"0:1440068396670937%6868637df9fd7ecd"}]}' diff --git a/tests/runtests.py b/tests/runtests.py new file mode 100755 index 0000000..8e67f3d --- /dev/null +++ b/tests/runtests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +import os +import sys +import unittest + + +def setup(): + """ + set up test environment + """ + + # add test/src folders to sys path + test_folder = os.path.abspath(os.path.dirname(__file__)) + src_folder = os.path.abspath(os.path.join(test_folder, os.pardir)) + sys.path.insert(0, test_folder) + sys.path.insert(0, src_folder) + + # define settings + import django.conf + os.environ[django.conf.ENVIRONMENT_VARIABLE] = "settings" + + # set up environment + from django.test.utils import setup_test_environment + setup_test_environment() + + # See https://docs.djangoproject.com/en/dev/releases/1.7/#app-loading-changes + import django + if django.VERSION >= (1, 7, 0): + django.setup() + + # set up database + from django.db import connection + connection.creation.create_test_db() + + +def tear_down(): + """ + tear down test environment + """ + + # destroy test database + from django.db import connection + connection.creation.destroy_test_db("not_needed") + + # teardown environment + from django.test.utils import teardown_test_environment + teardown_test_environment() + + +# fire in the hole! +if __name__ == "__main__": + setup() + + import tests + unittest.main(module=tests) + + tear_down() diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..72f94d3 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,23 @@ +# assert warnings are enabled +import warnings +warnings.simplefilter("ignore", Warning) + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + } +} + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "push_notifications", +] + +SITE_ID = 1 +ROOT_URLCONF = "core.urls" + +SECRET_KEY = "foobar" diff --git a/tests/test_apns_push_payload.py b/tests/test_apns_push_payload.py new file mode 100644 index 0000000..d3ad372 --- /dev/null +++ b/tests/test_apns_push_payload.py @@ -0,0 +1,31 @@ +import mock +from django.test import TestCase +from push_notifications.apns import _apns_send, APNSDataOverflow + + +class APNSPushPayloadTest(TestCase): + def test_push_payload(self): + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + _apns_send("123", "Hello world", + badge=1, sound="chime", extra={"custom_data": 12345}, expiration=3, socket=socket) + p.assert_called_once_with("123", + b'{"aps":{"alert":"Hello world","badge":1,"sound":"chime"},"custom_data":12345}', 0, 3, 10) + + def test_localised_push_with_empty_body(self): + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + _apns_send("123", None, loc_key="TEST_LOC_KEY", expiration=3, socket=socket) + p.assert_called_once_with("123", b'{"aps":{"alert":{"loc-key":"TEST_LOC_KEY"}}}', 0, 3, 10) + + def test_using_extra(self): + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + _apns_send("123", "sample", extra={"foo": "bar"}, identifier=10, expiration=30, priority=10, socket=socket) + p.assert_called_once_with("123", b'{"aps":{"alert":"sample"},"foo":"bar"}', 10, 30, 10) + + def test_oversized_payload(self): + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + self.assertRaises(APNSDataOverflow, _apns_send, "123", "_" * 2049, socket=socket) + p.assert_has_calls([]) diff --git a/tests/test_gcm_push_payload.py b/tests/test_gcm_push_payload.py new file mode 100644 index 0000000..38173b4 --- /dev/null +++ b/tests/test_gcm_push_payload.py @@ -0,0 +1,28 @@ +import mock +import json +from django.test import TestCase +from push_notifications.gcm import gcm_send_message, gcm_send_bulk_message +from tests.mock_responses import GCM_PLAIN_RESPONSE, GCM_JSON_RESPONSE + + +class GCMPushPayloadTest(TestCase): + def test_push_payload(self): + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: + gcm_send_message("abc", {"message": "Hello world"}) + p.assert_called_once_with( + b"data.message=Hello+world®istration_id=abc", + "application/x-www-form-urlencoded;charset=UTF-8") + + def test_push_payload_params(self): + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: + gcm_send_message("abc", {"message": "Hello world"}, delay_while_idle=True, time_to_live=3600) + p.assert_called_once_with( + b"data.message=Hello+world&delay_while_idle=1®istration_id=abc&time_to_live=3600", + "application/x-www-form-urlencoded;charset=UTF-8") + + def test_bulk_push_payload(self): + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_JSON_RESPONSE) as p: + gcm_send_bulk_message(["abc", "123"], {"message": "Hello world"}) + p.assert_called_once_with( + b'{"data":{"message":"Hello world"},"registration_ids":["abc","123"]}', + "application/json") diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py new file mode 100644 index 0000000..4534db0 --- /dev/null +++ b/tests/test_management_commands.py @@ -0,0 +1,25 @@ +import mock + +from django.core.management import call_command + +from django.test import TestCase +from push_notifications.apns import _apns_send, APNSDataOverflow + + +class CommandsTestCase(TestCase): + + def test_prune_devices(self): + from push_notifications.models import APNSDevice + + device = APNSDevice.objects.create( + registration_id="616263", # hex encoding of b'abc' + ) + with mock.patch( + 'push_notifications.apns._apns_create_socket_to_feedback', + mock.MagicMock()): + with mock.patch('push_notifications.apns._apns_receive_feedback', + mock.MagicMock()) as receiver: + receiver.side_effect = lambda s: [(b'', b'abc')] + call_command('prune_devices') + device = APNSDevice.objects.get(pk=device.pk) + self.assertFalse(device.active) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..375e1ae --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,232 @@ +import json +import mock +from django.test import TestCase +from django.utils import timezone +from push_notifications.models import GCMDevice, APNSDevice +from tests.mock_responses import ( GCM_PLAIN_RESPONSE,GCM_MULTIPLE_JSON_RESPONSE, GCM_PLAIN_RESPONSE_ERROR, + GCM_JSON_RESPONSE_ERROR, GCM_PLAIN_RESPONSE_ERROR_B, GCM_JSON_RESPONSE_ERROR_B, + GCM_PLAIN_CANONICAL_ID_RESPONSE, GCM_JSON_CANONICAL_ID_RESPONSE, + GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE) +from push_notifications.gcm import GCMError + + +class ModelTestCase(TestCase): + def test_can_save_gcm_device(self): + device = GCMDevice.objects.create( + registration_id="a valid registration id" + ) + assert device.id is not None + assert device.date_created is not None + assert device.date_created.date() == timezone.now().date() + + def test_can_create_save_device(self): + device = APNSDevice.objects.create( + registration_id="a valid registration id" + ) + assert device.id is not None + assert device.date_created is not None + assert device.date_created.date() == timezone.now().date() + + def test_gcm_send_message(self): + device = GCMDevice.objects.create( + registration_id="abc", + ) + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: + device.send_message("Hello world") + p.assert_called_once_with( + b"data.message=Hello+world®istration_id=abc", + "application/x-www-form-urlencoded;charset=UTF-8") + + def test_gcm_send_message_extra(self): + device = GCMDevice.objects.create( + registration_id="abc", + ) + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: + device.send_message("Hello world", extra={"foo": "bar"}) + p.assert_called_once_with( + b"data.foo=bar&data.message=Hello+world®istration_id=abc", + "application/x-www-form-urlencoded;charset=UTF-8") + + def test_gcm_send_message_collapse_key(self): + device = GCMDevice.objects.create( + registration_id="abc", + ) + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_PLAIN_RESPONSE) as p: + device.send_message("Hello world", collapse_key="test_key") + p.assert_called_once_with( + b"collapse_key=test_key&data.message=Hello+world®istration_id=abc", + "application/x-www-form-urlencoded;charset=UTF-8") + + def test_gcm_send_message_to_multiple_devices(self): + GCMDevice.objects.create( + registration_id="abc", + ) + + GCMDevice.objects.create( + registration_id="abc1", + ) + + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: + GCMDevice.objects.all().send_message("Hello world") + p.assert_called_once_with( + json.dumps({ + "data": { "message": "Hello world" }, + "registration_ids": ["abc", "abc1"] + }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") + + def test_gcm_send_message_active_devices(self): + GCMDevice.objects.create( + registration_id="abc", + active=True + ) + + GCMDevice.objects.create( + registration_id="xyz", + active=False + ) + + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: + GCMDevice.objects.all().send_message("Hello world") + p.assert_called_once_with( + json.dumps({ + "data": { "message": "Hello world" }, + "registration_ids": ["abc"] + }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") + + def test_gcm_send_message_extra_to_multiple_devices(self): + GCMDevice.objects.create( + registration_id="abc", + ) + + GCMDevice.objects.create( + registration_id="abc1", + ) + + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: + GCMDevice.objects.all().send_message("Hello world", extra={"foo": "bar"}) + p.assert_called_once_with( + json.dumps({ + "data": { "foo": "bar", "message": "Hello world" }, + "registration_ids": ["abc", "abc1"] + }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") + + def test_gcm_send_message_collapse_to_multiple_devices(self): + GCMDevice.objects.create( + registration_id="abc", + ) + + GCMDevice.objects.create( + registration_id="abc1", + ) + + with mock.patch("push_notifications.gcm._gcm_send", return_value=GCM_MULTIPLE_JSON_RESPONSE) as p: + GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key") + p.assert_called_once_with( + json.dumps({ + "collapse_key": "test_key", + "data": { "message": "Hello world" }, + "registration_ids": ["abc", "abc1"] + }, separators=(",", ":"), sort_keys=True).encode("utf-8"), "application/json") + + def test_gcm_send_message_to_single_device_with_error(self): + # these errors are device specific, device.active will be set false + device_list = ['abc', 'abc1'] + self.create_devices(device_list) + for index, error in enumerate(GCM_PLAIN_RESPONSE_ERROR): + with mock.patch("push_notifications.gcm._gcm_send", + return_value=error) as p: + device = GCMDevice.objects. \ + get(registration_id=device_list[index]) + device.send_message("Hello World!") + assert GCMDevice.objects.get(registration_id=device_list[index]).active is False + + def test_gcm_send_message_to_single_device_with_error_b(self): + # these errors are not device specific, GCMError should be thrown + device_list = ['abc'] + self.create_devices(device_list) + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_PLAIN_RESPONSE_ERROR_B) as p: + device = GCMDevice.objects. \ + get(registration_id=device_list[0]) + with self.assertRaises(GCMError): + device.send_message("Hello World!") + assert GCMDevice.objects.get(registration_id=device_list[0]).active is True + + def test_gcm_send_message_to_multiple_devices_with_error(self): + device_list = ['abc', 'abc1', 'abc2'] + self.create_devices(device_list) + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_JSON_RESPONSE_ERROR) as p: + devices = GCMDevice.objects.all() + devices.send_message("Hello World") + assert GCMDevice.objects.get(registration_id=device_list[0]).active is False + assert GCMDevice.objects.get(registration_id=device_list[1]).active is True + assert GCMDevice.objects.get(registration_id=device_list[2]).active is False + + def test_gcm_send_message_to_multiple_devices_with_error_b(self): + device_list = ['abc', 'abc1', 'abc2'] + self.create_devices(device_list) + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_JSON_RESPONSE_ERROR_B) as p: + devices = GCMDevice.objects.all() + with self.assertRaises(GCMError): + devices.send_message("Hello World") + assert GCMDevice.objects.get(registration_id=device_list[0]).active is True + assert GCMDevice.objects.get(registration_id=device_list[1]).active is True + assert GCMDevice.objects.get(registration_id=device_list[2]).active is False + + def test_gcm_send_message_to_multiple_devices_with_canonical_id(self): + device_list = ['foo', 'bar'] + self.create_devices(device_list) + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_JSON_CANONICAL_ID_RESPONSE): + GCMDevice.objects.all().send_message("Hello World") + assert GCMDevice.objects.filter(registration_id=device_list[0]).exists() is False + assert GCMDevice.objects.filter(registration_id=device_list[1]).exists() is True + assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True + + def test_gcm_send_message_to_single_user_with_canonical_id(self): + old_registration_id = 'foo' + self.create_devices([old_registration_id]) + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_PLAIN_CANONICAL_ID_RESPONSE): + GCMDevice.objects.get(registration_id=old_registration_id).send_message("Hello World") + assert GCMDevice.objects.filter(registration_id=old_registration_id).exists() is False + assert GCMDevice.objects.filter(registration_id="NEW_REGISTRATION_ID").exists() is True + + def test_gcm_send_message_to_same_devices_with_canonical_id(self): + device_list = ['foo', 'bar'] + self.create_devices(device_list) + first_device_pk = GCMDevice.objects.get(registration_id='foo').pk + second_device_pk = GCMDevice.objects.get(registration_id='bar').pk + with mock.patch("push_notifications.gcm._gcm_send", + return_value=GCM_JSON_CANONICAL_ID_SAME_DEVICE_RESPONSE): + GCMDevice.objects.all().send_message("Hello World") + first_device = GCMDevice.objects.get(pk=first_device_pk) + second_device = GCMDevice.objects.get(pk=second_device_pk) + assert first_device.active is False + assert second_device.active is True + + def test_apns_send_message(self): + device = APNSDevice.objects.create( + registration_id="abc", + ) + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + device.send_message("Hello world", socket=socket, expiration=1) + p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"}}', 0, 1, 10) + + def test_apns_send_message_extra(self): + device = APNSDevice.objects.create( + registration_id="abc", + ) + socket = mock.MagicMock() + with mock.patch("push_notifications.apns._apns_pack_frame") as p: + device.send_message("Hello world", extra={"foo": "bar"}, socket=socket, identifier=1, expiration=2, priority=5) + p.assert_called_once_with("abc", b'{"aps":{"alert":"Hello world"},"foo":"bar"}', 1, 2, 5) + + def create_devices(self, devices): + for device in devices: + GCMDevice.objects.create( + registration_id=device, + ) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py new file mode 100644 index 0000000..8e5b486 --- /dev/null +++ b/tests/test_rest_framework.py @@ -0,0 +1,120 @@ +from django.test import TestCase +from push_notifications.api.rest_framework import APNSDeviceSerializer, GCMDeviceSerializer +from rest_framework.serializers import ValidationError +from tests.mock_responses import GCM_DRF_INVALID_HEX_ERROR, GCM_DRF_OUT_OF_RANGE_ERROR + + +class APNSDeviceSerializerTestCase(TestCase): + def test_validation(self): + # valid data - 32 bytes upper case + serializer = APNSDeviceSerializer(data={ + "registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE", + "name": "Apple iPhone 6+", + "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + }) + self.assertTrue(serializer.is_valid()) + + # valid data - 32 bytes lower case + serializer = APNSDeviceSerializer(data={ + "registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", + "name": "Apple iPhone 6+", + "device_id": "ffffffffffffffffffffffffffffffff", + }) + self.assertTrue(serializer.is_valid()) + + # valid data - 100 bytes upper case + serializer = APNSDeviceSerializer(data={ + "registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE", + "name": "Apple iPhone 6+", + "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + }) + self.assertTrue(serializer.is_valid()) + + # valid data - 100 bytes lower case + serializer = APNSDeviceSerializer(data={ + "registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", + "name": "Apple iPhone 6+", + "device_id": "ffffffffffffffffffffffffffffffff", + }) + self.assertTrue(serializer.is_valid()) + + # invalid data - device_id, registration_id + serializer = APNSDeviceSerializer(data={ + "registration_id": "invalid device token contains no hex", + "name": "Apple iPhone 6+", + "device_id": "ffffffffffffffffffffffffffffake", + }) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors["device_id"][0], '"ffffffffffffffffffffffffffffake" is not a valid UUID.') + self.assertEqual(serializer.errors["registration_id"][0], "Registration ID (device token) is invalid") + + +class GCMDeviceSerializerTestCase(TestCase): + def test_device_id_validation_pass(self): + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Galaxy Note 3", + "device_id": "0x1031af3b", + }) + self.assertTrue(serializer.is_valid()) + + def test_registration_id_unique(self): + """Validate that a duplicate registration id raises a validation error.""" + + # add a device + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Galaxy Note 3", + "device_id": "0x1031af3b", + }) + serializer.is_valid(raise_exception=True) + obj = serializer.save() + + # ensure updating the same object works + serializer = GCMDeviceSerializer(obj, data={ + "registration_id": "foobar", + "name": "Galaxy Note 5", + "device_id": "0x1031af3b", + }) + serializer.is_valid(raise_exception=True) + obj = serializer.save() + + # try to add a new device with the same token + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Galaxy Note 3", + "device_id": "0xdeadbeaf", + }) + + with self.assertRaises(ValidationError) as ex: + serializer.is_valid(raise_exception=True) + self.assertEqual({'registration_id': [u'This field must be unique.']}, ex.exception.detail) + + def test_device_id_validation_fail_bad_hex(self): + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Galaxy Note 3", + "device_id": "0x10r", + }) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, GCM_DRF_INVALID_HEX_ERROR) + + def test_device_id_validation_fail_out_of_range(self): + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Galaxy Note 3", + "device_id": "10000000000000000", # 2**64 + }) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, GCM_DRF_OUT_OF_RANGE_ERROR) + + def test_device_id_validation_value_between_signed_unsigned_64b_int_maximums(self): + """ + 2**63 < 0xe87a4e72d634997c < 2**64 + """ + serializer = GCMDeviceSerializer(data={ + "registration_id": "foobar", + "name": "Nexus 5", + "device_id": "e87a4e72d634997c", + }) + self.assertTrue(serializer.is_valid()) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8418802 --- /dev/null +++ b/tox.ini @@ -0,0 +1,20 @@ +[tox] +envlist = {py27,py34,py35}--django{18,19}--drf{32,33},flake8 + +[testenv] +commands = python ./tests/runtests.py +deps= + django18: Django>=1.8,<1.9 + django19: Django>=1.9,<2.0 + mock==1.0.1 + drf32: djangorestframework>=3.2,<3.3 + drf33: djangorestframework>=3.3,<3.4 + +[testenv:flake8] +commands = flake8 push_notifications +deps = flake8 + +[flake8] +ignore = F403,W191,E126,E128 +max-line-length = 160 +exclude = push_notifications/migrations/*