├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── ios_notifications ├── __init__.py ├── admin.py ├── api.py ├── decorators.py ├── exceptions.py ├── forms.py ├── http.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── call_feedback_service.py │ │ └── push_ios_notification.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── south_migrations │ ├── 0001_initial.py │ ├── 0002_auto__add_field_notification_custom_payload__chg_field_notification_so.py │ ├── 0003_auto__add_field_notification_loc_payload.py │ ├── 0004_auto__add_field_notification_silent.py │ └── __init__.py ├── templates │ └── admin │ │ └── ios_notifications │ │ └── notification │ │ ├── change_form.html │ │ └── push_notification.html ├── test.pem ├── tests.py ├── urls.py └── utils.py ├── requirements.txt ├── runtests.sh ├── setup.py └── test └── testapp ├── manage.py └── testapp ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | *.swp 5 | build 6 | .DS_Store 7 | dist 8 | *.egg-info 9 | env/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | script: ./runtests.sh 5 | env: 6 | - DJANGO_VERSION=1.6.8 7 | - DJANGO_VERSION=1.7.8 8 | - DJANGO_VERSION=1.8.2 9 | - DJANGO_VERSION=1.9.4 10 | 11 | install: 12 | - pip install -q Django==$DJANGO_VERSION 13 | - pip install -r requirements.txt 14 | - python setup.py -q install 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Stephen Muss 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name Django iOS Notifications (django-ios-notifications) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL STEPHEN MUSS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include ios_notifications/test.pem 4 | recursive-include ios_notifications/templates * 5 | recursive-include ios_notifications/management * 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-ios-notifications 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/stephenmuss/django-ios-notifications.svg?branch=master)](https://travis-ci.org/stephenmuss/django-ios-notifications) 5 | 6 | Django iOS Notifications makes it easy to send push notifications to iOS devices. 7 | 8 | 9 | Installation 10 | ----------------- 11 | 12 | 13 | Minimum Requirements 14 | 15 | * Python 2.7 16 | * Django 1.6 or greater 17 | 18 | Two hard dependencies: 19 | 20 | * `pyOpenSSL >= 0.10` 21 | * `django-fields >= 0.2.2` 22 | 23 | * * * 24 | 25 | 1. You can install with pip: 26 | * `pip install django-ios-notifications` to get the latest release version 27 | * `pip install git+https://github.com/stephenmuss/django-ios-notifications.git#egg=django-ios-notifications` to install the latest bleeding-edge/development version 28 | 2. Add `ios_notifications` to `INSTALLED_APPS` in settings file. 29 | 3. If you want to use the API for registering devices you will need to make the appropriate changes to your urls file. 30 | * `url(r'^ios-notifications/', include('ios_notifications.urls'))` 31 | 4. Create required database tables. 32 | * `./manage.py syncdb` 33 | * If using south `./manage.py migrate ios_notifications` also see older django note below. 34 | 35 | 36 | Django 1.6 or below 37 | ------------------- 38 | 39 | You must configure south by adding the following lines to your settings file. 40 | 41 | ``` 42 | SOUTH_MIGRATION_MODULES = { 43 | 'ios_notifications': 'ios_notifications.south_migrations', 44 | } 45 | ``` 46 | 47 | 48 | Notes on Upgrading to 0.2.0 49 | ----------------- 50 | If you are upgrading to 0.2.0 from an older verion and you use password protection in any of your `APNService`s you will need to renter the password and resave the model for each one. 51 | This is due to changes in more recent versions of `django-fields`. 52 | 53 | 54 | Setting up the APN Services 55 | ----------------- 56 | 57 | Before you can add some devices and push notifications you'll need to set up an APN Service. 58 | An example of how to do this in a development environment follows. 59 | 60 | Start up your development server: `./manage.py runserver` and open up the following url in a web browser: 61 | 62 | http://127.0.0.1:8000/admin/ios_notifications/apnservice/add/. 63 | You'll see a form to be able to create a new APN Service. 64 | 65 | I am making the assumption that you have already created a private key and certificate. 66 | If not I suggest you follow one of the online guides to complete this step. 67 | One such example can be found [here](http://www.raywenderlich.com/3443/apple-push-notification-services-tutorial-part-12). 68 | 69 | The name of the service can be any arbitrary string. 70 | 71 | The hostname will need to be a valid hostname for one of the Apple APN Service hosts. 72 | Currently this is `gateway.sandbox.push.apple.com` for sandbox testing and `gateway.push.apple.com` for production use. 73 | 74 | For the certificate and private key fields paste in your certificate and key including the lines with: 75 | 76 | ``` 77 | ----BEGIN CERTIFICATE----- 78 | -----END CERTIFICATE----- 79 | -----BEGIN RSA PRIVATE KEY----- 80 | -----END RSA PRIVATE KEY----- 81 | ``` 82 | 83 | If your private key requires a passphrase be sure to enter it in to the `passphrase` field. 84 | Otherwise this field can be left blank. 85 | 86 | After this you are ready to save the APN Service. 87 | 88 | 89 | Registering devices 90 | ----------------- 91 | 92 | There are a few different ways you can register a device. You can either create the device in the admin interface at 93 | http://127.0.0.1:8000/admin/ios_notifications/device/add/ or use the API provided by django-ios-notifications to do so. 94 | 95 | If you want to add the device through the admin interface you will need to know the device's token represented by 64 96 | hexadecimal characters (be sure to exclude any `<`, `>` and whitespace characters). 97 | 98 | Otherwise the django-ios-notifications API provides a REST interface for you to be able to add the device; 99 | this would normally be done by sending a request from you iOS app. 100 | 101 | To register your device you will need to make a POST request from your device and pass the appropriate POST parameters in the request body. 102 | 103 | To create a new device you will need to call the API at http://127.0.0.1:8000/ios-notifications/device/ 104 | 105 | There are two required POST parameters required to complete this operation: 106 | * `token`: the device's 64 character hexadecimal token. 107 | * `service`: The id in integer format of the APNService instance to be used for this device. 108 | 109 | If the device already exists, the device's `is_active` attribute will be updated to `True`. Otherwise the device 110 | will be created. 111 | 112 | If successful the API will return the device in serialized JSON format with a status code of 201 if the device was created. If 113 | the device already existed the response code will be 200. 114 | 115 | 116 | Getting device details 117 | ----------------- 118 | 119 | To fetch the details of an existing device using the REST API you should call the following URL in an HTTP GET request: 120 | 121 | `http://127.0.0.1:8000/ios-notifications/device///` where: 122 | * `device_token` in the device's 64 character hexadecimal token. 123 | * `device_service` is the id in integer format of the device's related APNService model. 124 | 125 | For example: `http://127.0.0.1:8000/ios-notifications/device/0fd12510cfe6b0a4a89dc7369d96df956f991e66131dab63398734e8000d0029/1/`. 126 | 127 | This will return an HTTP response with the device in JSON format in the response body. 128 | 129 | 130 | Updating devices 131 | ----------------- 132 | 133 | The Django iOS Notifications REST interface also provides the means for you to be able to update 134 | a device via the API. 135 | 136 | To update a device you should call the same URL as you would above in *Getting device details*. The HTTP request method 137 | should be PUT. You can provide any of the following PUT parameters to update the device: 138 | 139 | * `users`: A list of user (django.contrib.auth.models.User) ids in integer formate associated with the device. 140 | * `platform`: A string describing the device's platform. Allowed options are 'iPhone', 'iPad' and 'iPod'. 141 | * `display`: A string describing the device's display (max 30 characters). e.g. '480x320'. 142 | * `os_version`: A string describing the device's OS Version (max 20 characters). e.g. 'iPhone OS 5.1.1' which would be 143 | the resulting string from `[NSString stringWithFormat:@"%@ %@", [[UIDevice currentDevice] systemName], [[UIDevice currentDevice] systemVersion]]`. 144 | 145 | Although technically permitted, updating any of the device's other attributes through the API is not recommended. 146 | 147 | This will return an HTTP response with the device with its updated attributes in JSON format in the response body. 148 | 149 | 150 | Creating and sending notifications 151 | ----------------- 152 | 153 | As with devices, there are a few different ways you can create notifications. 154 | 155 | You can create a notification in the admin interface by going to http://127.0.0.1:8000/admin/ios_notifications/notification/add/ 156 | 157 | If you create a notification and save it by hitting `Save and continue editing` you will notice that you 158 | are now able to push this notification by clicking the `Push now` button which has appeared. 159 | Clicking this button will send the notification to all active devices registered with the appropriate APN Server, 160 | so make sure that you are really ready to send it before clicking the button. 161 | 162 | Another options is to use the built in management command provided by django-ios-notifications. 163 | You can do this by calling `./manage.py push_ios_notification` from the command line. 164 | You will need to provide some arguments to the command in order to create and send a notification. 165 | 166 | 167 | There is only one required argument: 168 | 169 | * `--service` is the id of the APN Service you wish to use. e.g. `--service=123`. 170 | 171 | The optional arguments you may pass are: 172 | 173 | * `--message` is a string containing the main message of your notification. e.g. `--message='This is a push notification from Django iOS Notifications!'` 174 | * `--badge` is an integer value to represent the badge value that will appear over your app's springboard icon after receiving the notification. e.g. `--badge=2`. 175 | * `--sound` is the sound to be played when the device receives your application. This can either be one of the built in sounds or one that you have included in your app. e.g. `--sound=default`. 176 | * `--extra` is for specifying any extra custom payload values you want to send with your notification. This should be in the form of a valid JSON dictionary. e.g. `--extra='{"foo": "bar", "baz": [1, 2, 3], "qux": 1}'`. 177 | * `--persist` is for forcing persistence of notifications in the database. 178 | * `--no-persist` will not save the notification to the database. 179 | 180 | Note that in order to play a sound the `--sound` parameter must be supplied. Likewise, to display a badge number on the app icon 181 | the `--badge` parameter should be supplied. 182 | 183 | A full example: 184 | ```bash 185 | ./manage.py push_ios_notification \ 186 | --message='This is a push notification from Django iOS Notifications!' \ 187 | --service=123 \ 188 | --badge=1 \ 189 | --sound=default \ 190 | --extra='{"foo": "bar", "baz": [1, 2, 3], "qux": 1}' \ 191 | --persist 192 | ``` 193 | 194 | 195 | Sending a notification to a subset of devices. 196 | ----------------- 197 | 198 | If you wish to send a notification to just a subset of devices you can use the django-ios-notifications API to easily do so. 199 | The main assumption here is that you will have some way of knowing to which devices you wish to push a notification. 200 | 201 | Below follows a simple example of how to push a notification to a subset of devices based of their unique push tokens. 202 | 203 | Note that the notification is sent to devices in chunks. The chunk size can be specified in `APNService.push_notification_to_devices`. 204 | The default chunk size is 100. There is some debate on the ideal chunk size, but using chunks larger than a few hundred at a time is 205 | not recommended. 206 | 207 | ```python 208 | device_tokens = ('97bc2e598e1a11e2bacfb8f6b113c99597bd77428e1a11e2ae36b8f6b113c995', 209 | '9c97e3d78e1a11e28470b8f6b113c9959c97e5a38e1a11e28fd6b8f6b113c995', 210 | 'ba32393d8e1a11e28035b8f6b113c995ba323b0a8e1a11e28254b8f6b113c995', 211 | 'c71667578e1a11e2a5cfb8f6b113c995c716692e8e1a11e29c74b8f6b113c995') 212 | 213 | apns = APNService.objects.get(hostname='gateway.push.apple.com', name='production') 214 | devices = Device.objects.filter(token__in=device_tokens, service=apns) 215 | notification = Notification.objects.create(message='Some message', service=apns) 216 | apns.push_notification_to_devices(notification, devices, chunk_size=200) # Override the default chunk size to 200 (instead of 100) 217 | ``` 218 | 219 | Note, you simply need to use the `APNService.push_notification_to_devices` method to push a notification to the devices. 220 | 221 | 222 | Connecting to the APNService. 223 | ----------------- 224 | 225 | When you push a notification, a connection to the APNService is opened. It should be noted that this can raise 226 | an exception if a problem occurred when attempting to make the connection. 227 | 228 | See the [pyOpenSSL documentation](http://pythonhosted.org/pyOpenSSL/openssl-ssl.html#openssl-ssl) for more information. 229 | 230 | 231 | Notification persistence 232 | ----------------- 233 | 234 | By default notification objects are saved to the database. If you do not require this behaviour it is possible 235 | to disable notification persistence. 236 | 237 | In your `settings.py` file include the following: 238 | 239 | ```python 240 | IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS = False 241 | ``` 242 | 243 | 244 | API Authentication 245 | ----------------- 246 | 247 | At present the REST API supports a few different modes of authentication. 248 | 249 | If you plan to use the API then you need to specify `IOS_NOTIFICATIONS_AUTHENTICATION` in your settings.py file. 250 | 251 | The value of `IOS_NOTIFICATIONS_AUTHENTICATION` must be one of the following strings `AuthBasic`, `AuthBasicIsStaff` or `AuthNone`. 252 | 253 | ### `AuthNone` 254 | 255 | This is the setting to use if you don't care about protecting the API. Any request will be allowed to be processed by the API. 256 | This is the easiest to get started with but not really recommended. 257 | 258 | 259 | ### `AuthBasic` 260 | 261 | This will secure your API with basic access authentication. This means any request to the API will need to include an `Authorization` header. 262 | This will do a check to see whether a user exists in your database with the supplied credentials. The user should be an instance of `django.contrib.auth.models.User`. 263 | The value of the header will be the word `Basic` followed by a base64 encoded string of the user's username and password joined by a colon `:`. 264 | For example, if you have a user with the username `Aladdin` and password `open sesame` you would need to base64 encode the string `Aladdin:open sesame`. 265 | The resulting header should looks as follows `Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==`. It is highly recommended that you only send requests 266 | over SSL. Otherwise the user credentials will be sent unencrypted in plain text. 267 | 268 | See [Basic access authentication](http://en.wikipedia.org/wiki/Basic_access_authentication) for more details 269 | 270 | 271 | ### `AuthBasicIsStaff` 272 | 273 | This is the same as `AuthBasic` except that the request will only be allowed if the user is a staff user. 274 | 275 | 276 | The Feedback Service and deactivating devices 277 | ----------------- 278 | 279 | The Feedback Service is used to determine to which devices you should no longer push notifications. 280 | This is normally the case once a user has uninstalled your app from his or her device. 281 | 282 | [According to Apple](https://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3): 283 | 284 | > APNs monitors providers for their diligence in checking the feedback service and refraining from sending push notifications to nonexistent applications on devices. 285 | 286 | 287 | So it is good practice to ensure that you don't push notifications to devices which no longer have your app installed. 288 | 289 | Django iOS Notifications provides a `FeedbackService` class for you to discover to which devices you should no longer 290 | send notifications. 291 | 292 | You can add a FeedbackService in the admin via http://127.0.0.1:8000/admin/ios_notifications/feedbackservice/add/. 293 | Hopefully by now it should be self-explanatory what the fields are for this class. 294 | 295 | As with the `APNService` you will need to provide a hostname for any instances of `FeedbackService`. 296 | For sandbox environments you can currently use `feedback.sandbox.push.apple.com` and in production you should use `feedback.push.apple.com`. 297 | 298 | You should set the APNService relationship for FeedbackService according to your environment. 299 | 300 | Once you have created your FeedbackService instance you can call it to deactivate any devices it informs you of. 301 | 302 | To do this you can run the `call_feedback_service` management command. This will call the feedback service and deactivating any devices 303 | it is informed of by the service (by setting `is_active` to `False`). 304 | 305 | The `call_feedback_service` command takes one required argument: 306 | 307 | * --feedback-service: The id of the FeedbackService to call. e.g. `--feedback-service=123`. 308 | 309 | A full example: `./manage.py call_feedback_service --feedback-service=123` 310 | 311 | __NOTE:__ You may experience some issues testing the feedback service in a sandbox enviroment. 312 | This occurs when an app was the last push enabled app for that particular APN Service on the device 313 | Once the app is removed it tears down the persistent connection to the APN service. If you want to 314 | test a feedback service, ensure that you have at least one other app on the device installed which 315 | receives notifications from the same APN service. 316 | 317 | In the case that you want to test an app using the sandbox APN service, I suggest you create another 318 | dummy app in XCode and in the iOS provisioning portal with push notifications enabled. Install this app 319 | on any devices you are testing as well as the current app. Now you should be able to uninstall your app 320 | from the device and try pushing a notification. So long as the dummy app is still installed on your device 321 | the next time you attempt to call the feedback service all should go according to plan and you will notice 322 | the device in question has now been deactivated when you view it in the admin interface at 323 | http://127.0.0.1:8000/admin/ios_notifications/device/ 324 | 325 | See [Issues with Using the Feedback Service](http://developer.apple.com/library/ios/#technotes/tn2265/_index.html 326 | for more details) 327 | 328 | 329 | Contributors 330 | ----------------- 331 | Stephen Muss 332 | 333 | Maxime Bargiel 334 | 335 | *** 336 | 337 | This source code is released under a New BSD License. See the LICENSE file for full details. 338 | -------------------------------------------------------------------------------- /ios_notifications/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | VERSION = '0.2.0' 4 | -------------------------------------------------------------------------------- /ios_notifications/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.conf.urls import url 4 | from django.contrib import admin 5 | from django.template.response import TemplateResponse 6 | from django.shortcuts import get_object_or_404 7 | from .models import Device, Notification, APNService, FeedbackService 8 | from .forms import APNServiceForm 9 | 10 | 11 | class APNServiceAdmin(admin.ModelAdmin): 12 | list_display = ('name', 'hostname') 13 | form = APNServiceForm 14 | 15 | 16 | class DeviceAdmin(admin.ModelAdmin): 17 | fields = ('token', 'is_active', 'service') 18 | list_display = ('token', 'is_active', 'service', 'last_notified_at', 'platform', 'display', 'os_version', 'added_at', 'deactivated_at') 19 | list_filter = ('is_active', 'last_notified_at', 'added_at', 'deactivated_at') 20 | search_fields = ('token', 'platform') 21 | 22 | 23 | class NotificationAdmin(admin.ModelAdmin): 24 | exclude = ('last_sent_at',) 25 | list_display = ('message', 'badge', 'sound', 'custom_payload', 'created_at', 'last_sent_at',) 26 | list_filter = ('created_at', 'last_sent_at') 27 | search_fields = ('message', 'custom_payload') 28 | list_display_links = ('message', 'custom_payload',) 29 | 30 | def get_urls(self): 31 | urls = super(NotificationAdmin, self).get_urls() 32 | notification_urls = [ 33 | url(r'^(?P\d+)/push-notification/$', self.admin_site.admin_view(self.admin_push_notification), 34 | name='admin_push_notification'), 35 | ] 36 | return notification_urls + urls 37 | 38 | def admin_push_notification(self, request, **kwargs): 39 | notification = get_object_or_404(Notification, **kwargs) 40 | num_devices = 0 41 | if request.method == 'POST': 42 | service = notification.service 43 | num_devices = service.device_set.filter(is_active=True).count() 44 | notification.service.push_notification_to_devices(notification) 45 | request.current_app = 'ios_notifications' 46 | return TemplateResponse(request, 'admin/ios_notifications/notification/push_notification.html', 47 | {'notification': notification, 'num_devices': num_devices, 'sent': request.method == 'POST'}) 48 | 49 | admin.site.register(Device, DeviceAdmin) 50 | admin.site.register(Notification, NotificationAdmin) 51 | admin.site.register(APNService, APNServiceAdmin) 52 | admin.site.register(FeedbackService) 53 | -------------------------------------------------------------------------------- /ios_notifications/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | import django 5 | from django.http import HttpResponseNotAllowed, QueryDict 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.shortcuts import get_object_or_404 8 | from django.db import IntegrityError 9 | from django.contrib.auth.models import User 10 | from django.utils.decorators import method_decorator 11 | 12 | from .models import Device 13 | from .forms import DeviceForm 14 | from .decorators import api_authentication_required 15 | from .http import HttpResponseNotImplemented, JSONResponse 16 | 17 | 18 | class BaseResource(object): 19 | """ 20 | The base class for any API Resources. 21 | """ 22 | allowed_methods = ('GET', 'POST', 'PUT', 'DELETE') 23 | 24 | @method_decorator(api_authentication_required) 25 | @csrf_exempt 26 | def route(self, request, **kwargs): 27 | method = request.method 28 | if method in self.allowed_methods: 29 | if hasattr(self, method.lower()): 30 | if method == 'PUT': 31 | request.PUT = QueryDict(request.body if django.VERSION >= (1, 4) else request.raw_post_data).copy() 32 | return getattr(self, method.lower())(request, **kwargs) 33 | 34 | return HttpResponseNotImplemented() 35 | 36 | return HttpResponseNotAllowed(self.allowed_methods) 37 | 38 | 39 | class DeviceResource(BaseResource): 40 | """ 41 | The API resource for ios_notifications.models.Device. 42 | 43 | Allowed HTTP methods are GET, POST and PUT. 44 | """ 45 | allowed_methods = ('GET', 'POST', 'PUT') 46 | 47 | def get(self, request, **kwargs): 48 | """ 49 | Returns an HTTP response with the device in serialized JSON format. 50 | The device token and device service are expected as the keyword arguments 51 | supplied by the URL. 52 | 53 | If the device does not exist a 404 will be raised. 54 | """ 55 | device = get_object_or_404(Device, **kwargs) 56 | return JSONResponse(device) 57 | 58 | def post(self, request, **kwargs): 59 | """ 60 | Creates a new device or updates an existing one to `is_active=True`. 61 | Expects two non-options POST parameters: `token` and `service`. 62 | """ 63 | token = request.POST.get('token') 64 | if token is not None: 65 | # Strip out any special characters that may be in the token 66 | token = re.sub('<|>|\s', '', token) 67 | devices = Device.objects.filter(token=token, 68 | service__id=int(request.POST.get('service', 0))) 69 | if devices.exists(): 70 | device = devices.get() 71 | device.is_active = True 72 | device.save() 73 | return JSONResponse(device) 74 | form = DeviceForm(request.POST) 75 | if form.is_valid(): 76 | device = form.save(commit=False) 77 | device.is_active = True 78 | device.save() 79 | return JSONResponse(device, status=201) 80 | return JSONResponse(form.errors, status=400) 81 | 82 | def put(self, request, **kwargs): 83 | """ 84 | Updates an existing device. 85 | 86 | If the device does not exist a 404 will be raised. 87 | 88 | The device token and device service are expected as the keyword arguments 89 | supplied by the URL. 90 | 91 | Any attributes to be updated should be supplied as parameters in the request 92 | body of any HTTP PUT request. 93 | """ 94 | try: 95 | device = Device.objects.get(**kwargs) 96 | except Device.DoesNotExist: 97 | return JSONResponse({'error': 'Device with token %s and service %s does not exist' % 98 | (kwargs['token'], kwargs['service__id'])}, status=400) 99 | 100 | if 'users' in request.PUT: 101 | try: 102 | user_ids = request.PUT.getlist('users') 103 | device.users.remove(*[u.id for u in device.users.all()]) 104 | device.users.add(*User.objects.filter(id__in=user_ids)) 105 | except (ValueError, IntegrityError) as e: 106 | return JSONResponse({'error': e.message}, status=400) 107 | del request.PUT['users'] 108 | 109 | for key, value in request.PUT.items(): 110 | setattr(device, key, value) 111 | device.save() 112 | 113 | return JSONResponse(device) 114 | 115 | 116 | class Router(object): 117 | """ 118 | A simple class for handling URL routes. 119 | """ 120 | def __init__(self): 121 | self.device = DeviceResource().route 122 | 123 | routes = Router() 124 | -------------------------------------------------------------------------------- /ios_notifications/decorators.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | from django.contrib.auth import authenticate 4 | 5 | from .settings import get_setting 6 | from .http import JSONResponse 7 | 8 | 9 | class InvalidAuthenticationType(Exception): 10 | pass 11 | 12 | 13 | # TODO: OAuth 14 | VALID_AUTH_TYPES = ('AuthBasic', 'AuthBasicIsStaff', 'AuthNone') 15 | 16 | 17 | def api_authentication_required(func): 18 | """ 19 | Check the value of IOS_NOTIFICATIONS_AUTHENTICATION in settings 20 | and authenticate the request user appropriately. 21 | """ 22 | def wrapper(request, *args, **kwargs): 23 | AUTH_TYPE = get_setting('IOS_NOTIFICATIONS_AUTHENTICATION') 24 | if AUTH_TYPE not in VALID_AUTH_TYPES: 25 | raise InvalidAuthenticationType('IOS_NOTIFICATIONS_AUTHENTICATION must be specified in your settings.py file.\ 26 | Valid options are "AuthBasic", "AuthBasicIsStaff" or "AuthNone"') 27 | # Basic Authorization 28 | elif AUTH_TYPE == 'AuthBasic' or AUTH_TYPE == 'AuthBasicIsStaff': 29 | if 'HTTP_AUTHORIZATION' in request.META: 30 | auth_type, encoded_user_password = request.META['HTTP_AUTHORIZATION'].split(' ') 31 | if (auth_type != 'Basic'): 32 | return JSONResponse({'error': 'Invalid Authorization header, expected Basic'}, status=401) 33 | try: 34 | userpass = encoded_user_password.decode('base64') 35 | except binascii.Error: 36 | return JSONResponse({'error': 'invalid base64 encoded header'}, status=401) 37 | try: 38 | username, password = userpass.split(':') 39 | except ValueError: 40 | return JSONResponse({'error': 'malformed Authorization header'}, status=401) 41 | user = authenticate(username=username, password=password) 42 | if user is not None: 43 | if AUTH_TYPE == 'AuthBasic' or user.is_staff: 44 | return func(request, *args, **kwargs) 45 | return JSONResponse({'error': 'authentication error'}, status=401) 46 | return JSONResponse({'error': 'Authorization header not set'}, status=401) 47 | 48 | # AuthNone: No authorization. 49 | return func(request, *args, **kwargs) 50 | return wrapper 51 | -------------------------------------------------------------------------------- /ios_notifications/exceptions.py: -------------------------------------------------------------------------------- 1 | class NotificationPayloadSizeExceeded(Exception): 2 | def __init__(self, message='The notification maximum payload size of 256 bytes was exceeded'): 3 | super(NotificationPayloadSizeExceeded, self).__init__(message) 4 | 5 | 6 | class NotConnectedException(Exception): 7 | def __init__(self, message='You must open a socket connection before writing a message'): 8 | super(NotConnectedException, self).__init__(message) 9 | 10 | 11 | class InvalidPassPhrase(Exception): 12 | def __init__(self, message='The passphrase for the private key appears to be invalid'): 13 | super(InvalidPassPhrase, self).__init__(message) 14 | -------------------------------------------------------------------------------- /ios_notifications/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django import forms 4 | from django.forms.widgets import PasswordInput 5 | 6 | import OpenSSL 7 | from .models import Device, APNService 8 | 9 | 10 | class DeviceForm(forms.ModelForm): 11 | class Meta: 12 | model = Device 13 | fields = '__all__' 14 | 15 | 16 | class APNServiceForm(forms.ModelForm): 17 | class Meta: 18 | model = APNService 19 | fields = '__all__' 20 | 21 | START_CERT = '-----BEGIN CERTIFICATE-----' 22 | END_CERT = '-----END CERTIFICATE-----' 23 | START_KEY = '-----BEGIN RSA PRIVATE KEY-----' 24 | END_KEY = '-----END RSA PRIVATE KEY-----' 25 | START_ENCRYPTED_KEY = '-----BEGIN ENCRYPTED PRIVATE KEY-----' 26 | END_ENCRYPTED_KEY = '-----END ENCRYPTED PRIVATE KEY-----' 27 | 28 | passphrase = forms.CharField(widget=PasswordInput(render_value=True), required=False) 29 | 30 | def clean_certificate(self): 31 | if not self.START_CERT or not self.END_CERT in self.cleaned_data['certificate']: 32 | raise forms.ValidationError('Invalid certificate') 33 | return self.cleaned_data['certificate'] 34 | 35 | def clean_private_key(self): 36 | has_start_phrase = self.START_KEY in self.cleaned_data['private_key'] \ 37 | or self.START_ENCRYPTED_KEY in self.cleaned_data['private_key'] 38 | has_end_phrase = self.END_KEY in self.cleaned_data['private_key'] \ 39 | or self.END_ENCRYPTED_KEY in self.cleaned_data['private_key'] 40 | if not has_start_phrase or not has_end_phrase: 41 | raise forms.ValidationError('Invalid private key') 42 | return self.cleaned_data['private_key'] 43 | 44 | def clean_passphrase(self): 45 | passphrase = self.cleaned_data['passphrase'] 46 | if passphrase is not None and len(passphrase) > 0: 47 | try: 48 | OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, self.cleaned_data['private_key'], str(passphrase)) 49 | except OpenSSL.crypto.Error: 50 | raise forms.ValidationError('The passphrase for the private key appears to be invalid') 51 | return self.cleaned_data['passphrase'] 52 | -------------------------------------------------------------------------------- /ios_notifications/http.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.db.models.query import QuerySet 4 | from django.http import HttpResponse 5 | from django.core import serializers 6 | 7 | 8 | class HttpResponseNotImplemented(HttpResponse): 9 | status_code = 501 10 | 11 | 12 | class JSONResponse(HttpResponse): 13 | """ 14 | A subclass of django.http.HttpResponse which serializes its content 15 | and returns a response with an application/json mimetype. 16 | """ 17 | def __init__(self, content=None, content_type=None, status=None, mimetype='application/json'): 18 | content = self.serialize(content) if content is not None else '' 19 | super(JSONResponse, self).__init__(content, content_type, status, mimetype) 20 | 21 | def serialize(self, obj): 22 | json_s = serializers.get_serializer('json')() 23 | if isinstance(obj, QuerySet): 24 | return json_s.serialize(obj) 25 | elif isinstance(obj, dict): 26 | return json.dumps(obj) 27 | 28 | serialized_list = json_s.serialize([obj]) 29 | m = json.loads(serialized_list)[0] 30 | return json.dumps(m) 31 | -------------------------------------------------------------------------------- /ios_notifications/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenmuss/django-ios-notifications/9600c496751668ae2bee70a13dbd157d984596e1/ios_notifications/management/__init__.py -------------------------------------------------------------------------------- /ios_notifications/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenmuss/django-ios-notifications/9600c496751668ae2bee70a13dbd157d984596e1/ios_notifications/management/commands/__init__.py -------------------------------------------------------------------------------- /ios_notifications/management/commands/call_feedback_service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | from ios_notifications.models import FeedbackService 5 | from optparse import make_option 6 | 7 | # TODO: argparse for Python 2.7 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Calls the Apple Feedback Service to determine which devices are no longer active and deactivates them in the database.' 12 | 13 | option_list = BaseCommand.option_list + ( 14 | make_option('--feedback-service', 15 | help='The id of the Feedback Service to call', 16 | dest='service', 17 | default=None),) 18 | 19 | def handle(self, *args, **options): 20 | if options['service'] is None: 21 | raise CommandError('The --feedback-service option is required') 22 | try: 23 | service_id = int(options['service']) 24 | except ValueError: 25 | raise CommandError('The --feedback-service option should pass an id in integer format as its value') 26 | try: 27 | service = FeedbackService.objects.get(pk=service_id) 28 | except FeedbackService.DoesNotExist: 29 | raise CommandError('FeedbackService with id %d does not exist' % service_id) 30 | 31 | num_deactivated = service.call() 32 | output = '%d device%s deactivated.\n' % (num_deactivated, ' was' if num_deactivated == 1 else 's were') 33 | self.stdout.write(output) 34 | -------------------------------------------------------------------------------- /ios_notifications/management/commands/push_ios_notification.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from optparse import make_option 4 | import json 5 | import sys 6 | 7 | from django.core.management.base import BaseCommand, CommandError 8 | 9 | from ios_notifications.models import Notification, APNService 10 | 11 | 12 | class Command(BaseCommand): 13 | help = 'Create and immediately send a push notification to iOS devices' 14 | option_list = BaseCommand.option_list + ( 15 | make_option('--message', 16 | help='The main message to be sent in the notification', 17 | dest='message', 18 | default=''), 19 | make_option('--badge', 20 | help='The badge number of the notification', 21 | dest='badge', 22 | default=None), 23 | make_option('--sound', 24 | help='The sound for the notification', 25 | dest='sound', 26 | default=''), 27 | make_option('--service', 28 | help='The id of the APN Service to send this notification through', 29 | dest='service', 30 | default=None), 31 | make_option('--extra', 32 | help='Custom notification payload values as a JSON dictionary', 33 | dest='extra', 34 | default=None), 35 | make_option('--persist', 36 | help='Save the notification in the database after pushing it.', 37 | action='store_true', 38 | dest='persist', 39 | default=None), 40 | make_option('--no-persist', 41 | help='Prevent saving the notification in the database after pushing it.', 42 | action='store_false', 43 | dest='persist'), # Note: same dest as --persist; they are mutually exclusive 44 | make_option('--batch-size', 45 | help='Notifications are sent to devices in batches via the APN Service. This controls the batch size. Default is 100.', 46 | dest='chunk_size', 47 | default=100), 48 | ) 49 | 50 | def handle(self, *args, **options): 51 | if options['service'] is None: 52 | raise CommandError('The --service option is required') 53 | try: 54 | service_id = int(options['service']) 55 | except ValueError: 56 | raise CommandError('The --service option should pass an id in integer format as its value') 57 | if options['badge'] is not None: 58 | try: 59 | options['badge'] = int(options['badge']) 60 | except ValueError: 61 | raise CommandError('The --badge option should pass an integer as its value') 62 | try: 63 | service = APNService.objects.get(pk=service_id) 64 | except APNService.DoesNotExist: 65 | raise CommandError('APNService with id %d does not exist' % service_id) 66 | 67 | message = options['message'] 68 | extra = options['extra'] 69 | 70 | if not message and not extra: 71 | raise CommandError('To send a notification you must provide either the --message or --extra option.') 72 | 73 | notification = Notification(message=options['message'], 74 | badge=options['badge'], 75 | service=service, 76 | sound=options['sound']) 77 | 78 | if options['persist'] is not None: 79 | notification.persist = options['persist'] 80 | 81 | if extra is not None: 82 | notification.extra = json.loads(extra) 83 | 84 | try: 85 | chunk_size = int(options['chunk_size']) 86 | except ValueError: 87 | raise CommandError('The --batch-size option should be an integer value.') 88 | 89 | if not notification.is_valid_length(): 90 | raise CommandError('Notification exceeds the maximum payload length. Try making your message shorter.') 91 | 92 | service.push_notification_to_devices(notification, chunk_size=chunk_size) 93 | if 'test' not in sys.argv: 94 | self.stdout.write('Notification pushed successfully\n') 95 | -------------------------------------------------------------------------------- /ios_notifications/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-03-26 01:00 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django_fields.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='APNService', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('name', models.CharField(max_length=255)), 25 | ('hostname', models.CharField(max_length=255)), 26 | ('certificate', models.TextField()), 27 | ('private_key', models.TextField()), 28 | ('passphrase', django_fields.fields.EncryptedCharField(blank=True, block_type=b'MODE_CBC', help_text=b'Passphrase for the private key', max_length=110, null=True)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name='Device', 33 | fields=[ 34 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 35 | ('token', models.CharField(max_length=64)), 36 | ('is_active', models.BooleanField(default=True)), 37 | ('deactivated_at', models.DateTimeField(blank=True, null=True)), 38 | ('added_at', models.DateTimeField(auto_now_add=True)), 39 | ('last_notified_at', models.DateTimeField(blank=True, null=True)), 40 | ('platform', models.CharField(blank=True, max_length=30, null=True)), 41 | ('display', models.CharField(blank=True, max_length=30, null=True)), 42 | ('os_version', models.CharField(blank=True, max_length=20, null=True)), 43 | ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ios_notifications.APNService')), 44 | ('users', models.ManyToManyField(blank=True, related_name='ios_devices', to=settings.AUTH_USER_MODEL)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='FeedbackService', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('name', models.CharField(max_length=255)), 52 | ('hostname', models.CharField(max_length=255)), 53 | ('apn_service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ios_notifications.APNService')), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Notification', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('message', models.CharField(blank=True, help_text=b'Alert message to display to the user. Leave empty if no alert should be displayed to the user.', max_length=200)), 61 | ('badge', models.PositiveIntegerField(blank=True, help_text=b'New application icon badge number. Set to None if the badge number must not be changed.', null=True)), 62 | ('silent', models.NullBooleanField(help_text=b'set True to send a silent notification')), 63 | ('sound', models.CharField(blank=True, help_text=b'Name of the sound to play. Leave empty if no sound should be played.', max_length=30)), 64 | ('created_at', models.DateTimeField(auto_now_add=True)), 65 | ('last_sent_at', models.DateTimeField(blank=True, null=True)), 66 | ('custom_payload', models.CharField(blank=True, help_text=b'JSON representation of an object containing custom payload.', max_length=240)), 67 | ('loc_payload', models.CharField(blank=True, help_text=b'JSON representation of an object containing the localization payload.', max_length=240)), 68 | ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ios_notifications.APNService')), 69 | ], 70 | ), 71 | migrations.AlterUniqueTogether( 72 | name='apnservice', 73 | unique_together=set([('name', 'hostname')]), 74 | ), 75 | migrations.AlterUniqueTogether( 76 | name='feedbackservice', 77 | unique_together=set([('name', 'hostname')]), 78 | ), 79 | migrations.AlterUniqueTogether( 80 | name='device', 81 | unique_together=set([('token', 'service')]), 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /ios_notifications/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django migrations for email_log app. 3 | 4 | This package does not contain South migrations. South migrations can be found 5 | in the ``south_migrations`` package. 6 | """ 7 | 8 | SOUTH_ERROR_MESSAGE = """\n 9 | For South support, customize the SOUTH_MIGRATION_MODULES setting like so: 10 | 11 | SOUTH_MIGRATION_MODULES = { 12 | 'ios_notifications': 'ios_notifications.south_migrations', 13 | } 14 | """ 15 | 16 | # Ensure the user is not using Django 1.6 or below with South 17 | try: 18 | from django.db import migrations # noqa 19 | except ImportError: 20 | from django.core.exceptions import ImproperlyConfigured 21 | raise ImproperlyConfigured(SOUTH_ERROR_MESSAGE) 22 | -------------------------------------------------------------------------------- /ios_notifications/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import socket 3 | import struct 4 | import errno 5 | import json 6 | from binascii import hexlify, unhexlify 7 | 8 | from django.db import models 9 | 10 | try: 11 | from django.utils.timezone import now as dt_now 12 | except ImportError: 13 | import datetime 14 | dt_now = datetime.datetime.now 15 | 16 | from django_fields.fields import EncryptedCharField 17 | import OpenSSL 18 | 19 | try: 20 | import gevent_openssl 21 | GEVENT_OPEN_SSL=True 22 | except: 23 | GEVENT_OPEN_SSL=False 24 | 25 | from .exceptions import NotificationPayloadSizeExceeded, InvalidPassPhrase 26 | from .settings import get_setting 27 | 28 | 29 | class BaseService(models.Model): 30 | """ 31 | A base service class intended to be subclassed. 32 | """ 33 | name = models.CharField(max_length=255) 34 | hostname = models.CharField(max_length=255) 35 | PORT = 0 # Should be overriden by subclass 36 | connection = None 37 | 38 | def _connect(self, certificate, private_key, passphrase=None): 39 | """ 40 | Establishes an encrypted SSL socket connection to the service. 41 | After connecting the socket can be written to or read from. 42 | """ 43 | # ssl in Python < 3.2 does not support certificates/keys as strings. 44 | # See http://bugs.python.org/issue3823 45 | # Therefore pyOpenSSL which lets us do this is a dependancy. 46 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 47 | cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, certificate) 48 | args = [OpenSSL.crypto.FILETYPE_PEM, private_key] 49 | if passphrase is not None: 50 | args.append(str(passphrase)) 51 | try: 52 | pkey = OpenSSL.crypto.load_privatekey(*args) 53 | except OpenSSL.crypto.Error: 54 | raise InvalidPassPhrase 55 | context = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) 56 | context.use_certificate(cert) 57 | context.use_privatekey(pkey) 58 | if GEVENT_OPEN_SSL: 59 | self.connection = gevent_openssl.SSL.Connection(context, sock) 60 | else: 61 | self.connection = OpenSSL.SSL.Connection(context, sock) 62 | self.connection.connect((self.hostname, self.PORT)) 63 | self.connection.set_connect_state() 64 | self.connection.do_handshake() 65 | 66 | def _disconnect(self): 67 | """ 68 | Closes the SSL socket connection. 69 | """ 70 | if self.connection is not None: 71 | self.connection.shutdown() 72 | self.connection.close() 73 | 74 | class Meta: 75 | abstract = True 76 | 77 | 78 | class APNService(BaseService): 79 | """ 80 | Represents an Apple Notification Service either for live 81 | or sandbox notifications. 82 | 83 | `private_key` is optional if both the certificate and key are provided in 84 | `certificate`. 85 | """ 86 | certificate = models.TextField() 87 | private_key = models.TextField() 88 | passphrase = EncryptedCharField( 89 | null=True, blank=True, help_text='Passphrase for the private key', 90 | block_type='MODE_CBC') 91 | 92 | PORT = 2195 93 | fmt = '!cH32sH%ds' 94 | 95 | def _connect(self): 96 | """ 97 | Establishes an encrypted SSL socket connection to the service. 98 | After connecting the socket can be written to or read from. 99 | """ 100 | return super(APNService, self)._connect(self.certificate, self.private_key, self.passphrase) 101 | 102 | def push_notification_to_devices(self, notification, devices=None, chunk_size=100): 103 | """ 104 | Sends the specific notification to devices. 105 | if `devices` is not supplied, all devices in the `APNService`'s device 106 | list will be sent the notification. 107 | """ 108 | if devices is None: 109 | devices = self.device_set.filter(is_active=True) 110 | self._write_message(notification, devices, chunk_size) 111 | 112 | def _write_message(self, notification, devices, chunk_size): 113 | """ 114 | Writes the message for the supplied devices to 115 | the APN Service SSL socket. 116 | """ 117 | if not isinstance(notification, Notification): 118 | raise TypeError('notification should be an instance of ios_notifications.models.Notification') 119 | 120 | if not isinstance(chunk_size, int) or chunk_size < 1: 121 | raise ValueError('chunk_size must be an integer greater than zero.') 122 | 123 | payload = notification.payload 124 | 125 | # Split the devices into manageable chunks. 126 | # Chunk sizes being determined by the `chunk_size` arg. 127 | device_length = devices.count() if isinstance(devices, models.query.QuerySet) else len(devices) 128 | chunks = [devices[i:i + chunk_size] for i in xrange(0, device_length, chunk_size)] 129 | 130 | for index in xrange(len(chunks)): 131 | chunk = chunks[index] 132 | self._connect() 133 | 134 | for device in chunk: 135 | if not device.is_active: 136 | continue 137 | try: 138 | self.connection.send(self.pack_message(payload, device)) 139 | except (OpenSSL.SSL.WantWriteError, socket.error) as e: 140 | if isinstance(e, socket.error) and isinstance(e.args, tuple) and e.args[0] != errno.EPIPE: 141 | raise e # Unexpected exception, raise it. 142 | self._disconnect() 143 | i = chunk.index(device) 144 | self.set_devices_last_notified_at(chunk[:i]) 145 | # Start again from the next device. 146 | # We start from the next device since 147 | # if the device no longer accepts push notifications from your app 148 | # and you send one to it anyways, Apple immediately drops the connection to your APNS socket. 149 | # http://stackoverflow.com/a/13332486/1025116 150 | self._write_message(notification, chunk[i + 1:], chunk_size) 151 | 152 | self._disconnect() 153 | 154 | self.set_devices_last_notified_at(chunk) 155 | 156 | if notification.pk or notification.persist: 157 | notification.last_sent_at = dt_now() 158 | notification.save() 159 | 160 | def set_devices_last_notified_at(self, devices): 161 | # Rather than do a save on every object, 162 | # fetch another queryset and use it to update 163 | # the devices in a single query. 164 | # Since the devices argument could be a sliced queryset 165 | # we can't rely on devices.update() even if devices is 166 | # a queryset object. 167 | Device.objects.filter(pk__in=[d.pk for d in devices]).update(last_notified_at=dt_now()) 168 | 169 | def pack_message(self, payload, device): 170 | """ 171 | Converts a notification payload into binary form. 172 | """ 173 | if len(payload) > 256: 174 | raise NotificationPayloadSizeExceeded 175 | if not isinstance(device, Device): 176 | raise TypeError('device must be an instance of ios_notifications.models.Device') 177 | 178 | msg = struct.pack(self.fmt % len(payload), chr(0), 32, unhexlify(device.token), len(payload), payload) 179 | return msg 180 | 181 | def __unicode__(self): 182 | return self.name 183 | 184 | class Meta: 185 | unique_together = ('name', 'hostname') 186 | 187 | 188 | class Notification(models.Model): 189 | """ 190 | Represents a notification which can be pushed to an iOS device. 191 | """ 192 | service = models.ForeignKey(APNService) 193 | message = models.CharField(max_length=2048, blank=True, help_text='Alert message to display to the user. Leave empty if no alert should be displayed to the user.') 194 | badge = models.PositiveIntegerField(null=True, blank=True, help_text='New application icon badge number. Set to None if the badge number must not be changed.') 195 | silent = models.NullBooleanField(null=True, blank=True, help_text='set True to send a silent notification') 196 | sound = models.CharField(max_length=30, blank=True, help_text='Name of the sound to play. Leave empty if no sound should be played.') 197 | created_at = models.DateTimeField(auto_now_add=True) 198 | last_sent_at = models.DateTimeField(null=True, blank=True) 199 | custom_payload = models.CharField(max_length=240, blank=True, help_text='JSON representation of an object containing custom payload.') 200 | loc_payload = models.CharField(max_length=240, blank=True, help_text="JSON representation of an object containing the localization payload.") 201 | 202 | def __init__(self, *args, **kwargs): 203 | self.persist = get_setting('IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS') 204 | super(Notification, self).__init__(*args, **kwargs) 205 | 206 | def __unicode__(self): 207 | return u'%s%s%s' % (self.message, ' ' if self.message and self.custom_payload else '', self.custom_payload) 208 | 209 | @property 210 | def extra(self): 211 | """ 212 | The extra property is used to specify custom payload values 213 | outside the Apple-reserved aps namespace 214 | http://developer.apple.com/library/mac/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW1 215 | """ 216 | return json.loads(self.custom_payload) if self.custom_payload else None 217 | 218 | @extra.setter 219 | def extra(self, value): 220 | if value is None: 221 | self.custom_payload = '' 222 | else: 223 | if not isinstance(value, dict): 224 | raise TypeError('must be a valid Python dictionary') 225 | self.custom_payload = json.dumps(value) # Raises a TypeError if can't be serialized 226 | 227 | @property 228 | def loc_data(self): 229 | """ 230 | The loc_data property is used to specify localization paramaters within the 'alert' aps key. 231 | https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html 232 | """ 233 | return json.loads(self.loc_payload) if self.loc_payload else None 234 | 235 | def set_loc_data(self, loc_key, loc_args, action_loc_key=None): 236 | if not isinstance(loc_args, (list, tuple)): 237 | raise TypeError("loc_args must be a list or tuple.") 238 | 239 | loc_data = { 240 | "loc-key": unicode(loc_key), 241 | "loc-args": [unicode(a) for a in loc_args], 242 | } 243 | 244 | if action_loc_key: 245 | loc_data['action-loc-key'] = unicode(action_loc_key) 246 | 247 | self.loc_payload = json.dumps(loc_data) 248 | 249 | def push_to_all_devices(self): 250 | """ 251 | Pushes this notification to all active devices using the 252 | notification's related APN service. 253 | """ 254 | self.service.push_notification_to_devices(self) 255 | 256 | def is_valid_length(self): 257 | """ 258 | Determines if a notification payload is a valid length. 259 | 260 | returns bool 261 | """ 262 | return len(self.payload) <= 256 263 | 264 | @property 265 | def payload(self): 266 | aps = {} 267 | 268 | loc_data = self.loc_data 269 | if loc_data: 270 | aps['alert'] = loc_data 271 | elif self.message: 272 | aps['alert'] = self.message 273 | 274 | if self.badge is not None: 275 | aps['badge'] = self.badge 276 | if self.sound: 277 | aps['sound'] = self.sound 278 | if self.silent: 279 | aps['content-available'] = 1 280 | message = {'aps': aps} 281 | extra = self.extra 282 | if extra is not None: 283 | message.update(extra) 284 | payload = json.dumps(message, separators=(',', ':'), ensure_ascii=False).encode('utf8') 285 | return payload 286 | 287 | 288 | class Device(models.Model): 289 | """ 290 | Represents an iOS device with unique token. 291 | """ 292 | token = models.CharField(max_length=64, blank=False, null=False) 293 | is_active = models.BooleanField(default=True) 294 | deactivated_at = models.DateTimeField(null=True, blank=True) 295 | service = models.ForeignKey(APNService) 296 | users = models.ManyToManyField(get_setting('AUTH_USER_MODEL'), blank=True, related_name='ios_devices') 297 | added_at = models.DateTimeField(auto_now_add=True) 298 | last_notified_at = models.DateTimeField(null=True, blank=True) 299 | platform = models.CharField(max_length=30, blank=True, null=True) 300 | display = models.CharField(max_length=30, blank=True, null=True) 301 | os_version = models.CharField(max_length=20, blank=True, null=True) 302 | 303 | def push_notification(self, notification): 304 | """ 305 | Pushes a ios_notifications.models.Notification instance to an the device. 306 | For more details see http://developer.apple.com/library/mac/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html 307 | """ 308 | if not isinstance(notification, Notification): 309 | raise TypeError('notification should be an instance of ios_notifications.models.Notification') 310 | 311 | self.service.push_notification_to_devices(notification, [self]) 312 | 313 | def __unicode__(self): 314 | return self.token 315 | 316 | class Meta: 317 | unique_together = ('token', 'service') 318 | 319 | 320 | class FeedbackService(BaseService): 321 | """ 322 | The service provided by Apple to inform you of devices which no longer have your app installed 323 | and to which notifications have failed a number of times. Use this class to check the feedback 324 | service and deactivate any devices it informs you about. 325 | 326 | https://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3 327 | """ 328 | apn_service = models.ForeignKey(APNService) 329 | 330 | PORT = 2196 331 | 332 | fmt = '!lh32s' 333 | 334 | def _connect(self): 335 | """ 336 | Establishes an encrypted socket connection to the feedback service. 337 | """ 338 | return super(FeedbackService, self)._connect(self.apn_service.certificate, self.apn_service.private_key, self.apn_service.passphrase) 339 | 340 | def call(self): 341 | """ 342 | Calls the feedback service and deactivates any devices the feedback service mentions. 343 | """ 344 | self._connect() 345 | device_tokens = [] 346 | try: 347 | while True: 348 | data = self.connection.recv(38) # 38 being the length in bytes of the binary format feedback tuple. 349 | if len(data) == 0: 350 | raise OpenSSL.SSL.ZeroReturnError() 351 | timestamp, token_length, token = struct.unpack(self.fmt, data) 352 | device_token = hexlify(token) 353 | device_tokens.append(device_token) 354 | except OpenSSL.SSL.ZeroReturnError: 355 | # Nothing to receive 356 | pass 357 | finally: 358 | self._disconnect() 359 | devices = Device.objects.filter(token__in=device_tokens, service=self.apn_service) 360 | devices.update(is_active=False, deactivated_at=dt_now()) 361 | return devices.count() 362 | 363 | def __unicode__(self): 364 | return self.name 365 | 366 | class Meta: 367 | unique_together = ('name', 'hostname') 368 | -------------------------------------------------------------------------------- /ios_notifications/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | defaults = { 4 | # Default user model if no custom user model is specified 5 | 'AUTH_USER_MODEL': 'auth.User', 6 | 7 | # Whether Notification model instances are automatically saved when they are pushed. 8 | # Expected values: True, False. 9 | 'IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS': True, 10 | 11 | # Indicates the type of authentication required by an API endpoint. 12 | # Expected values: one of 'AuthNone', 'AuthBasic', 'AuthBasicIsStaff'. 13 | # This setting MUST be set for the API to be usable. 14 | 'IOS_NOTIFICATIONS_AUTHENTICATION': None, 15 | } 16 | 17 | def get_setting(name): 18 | ''' 19 | Get user setting by name, providing ios_notification default if necessary. 20 | 21 | By design, this will crash if 'name' is neither a user app setting (default or user-specified) 22 | nor a valid ios_notifications setting that has a default value. 23 | ''' 24 | return getattr(settings, name, defaults[name]) 25 | -------------------------------------------------------------------------------- /ios_notifications/south_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | try: 8 | from django.contrib.auth import get_user_model 9 | except ImportError: # django < 1.5 10 | from django.contrib.auth.models import User 11 | else: 12 | User = get_user_model() 13 | 14 | # With the default User model these will be 'auth.User' and 'auth.user' 15 | # but for having a custom User model and instead of using orm['auth.User'] 16 | # we can use orm[user_orm_label] 17 | user_orm_label = '%s.%s' % (User._meta.app_label, User._meta.object_name) 18 | user_model_label = '%s.%s' % (User._meta.app_label, User._meta.module_name) 19 | 20 | class Migration(SchemaMigration): 21 | 22 | def forwards(self, orm): 23 | # Adding model 'APNService' 24 | db.create_table('ios_notifications_apnservice', ( 25 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 26 | ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), 27 | ('hostname', self.gf('django.db.models.fields.CharField')(max_length=255)), 28 | ('certificate', self.gf('django.db.models.fields.TextField')()), 29 | ('private_key', self.gf('django.db.models.fields.TextField')()), 30 | ('passphrase', self.gf('django_fields.fields.EncryptedCharField')(max_length=101, null=True, cipher='AES', blank=True)), 31 | )) 32 | db.send_create_signal('ios_notifications', ['APNService']) 33 | 34 | # Adding unique constraint on 'APNService', fields ['name', 'hostname'] 35 | db.create_unique('ios_notifications_apnservice', ['name', 'hostname']) 36 | 37 | # Adding model 'Notification' 38 | db.create_table('ios_notifications_notification', ( 39 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 40 | ('service', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ios_notifications.APNService'])), 41 | ('message', self.gf('django.db.models.fields.CharField')(max_length=200)), 42 | ('badge', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, null=True)), 43 | ('sound', self.gf('django.db.models.fields.CharField')(default='default', max_length=30, null=True)), 44 | ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 45 | ('last_sent_at', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 46 | )) 47 | db.send_create_signal('ios_notifications', ['Notification']) 48 | 49 | # Adding model 'Device' 50 | db.create_table('ios_notifications_device', ( 51 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 52 | ('token', self.gf('django.db.models.fields.CharField')(max_length=64)), 53 | ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)), 54 | ('deactivated_at', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 55 | ('service', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ios_notifications.APNService'])), 56 | ('added_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 57 | ('last_notified_at', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), 58 | ('platform', self.gf('django.db.models.fields.CharField')(max_length=30, null=True, blank=True)), 59 | ('display', self.gf('django.db.models.fields.CharField')(max_length=30, null=True, blank=True)), 60 | ('os_version', self.gf('django.db.models.fields.CharField')(max_length=20, null=True, blank=True)), 61 | )) 62 | db.send_create_signal('ios_notifications', ['Device']) 63 | 64 | # Adding unique constraint on 'Device', fields ['token', 'service'] 65 | db.create_unique('ios_notifications_device', ['token', 'service_id']) 66 | 67 | # Adding M2M table for field users on 'Device' 68 | db.create_table('ios_notifications_device_users', ( 69 | ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), 70 | ('device', models.ForeignKey(orm['ios_notifications.device'], null=False)), 71 | (User._meta.module_name, self.gf('django.db.models.fields.related.ForeignKey')(to=orm[user_orm_label])), 72 | )) 73 | db.create_unique('ios_notifications_device_users', ['device_id', '%s_id' % User._meta.module_name]) 74 | 75 | # Adding model 'FeedbackService' 76 | db.create_table('ios_notifications_feedbackservice', ( 77 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 78 | ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), 79 | ('hostname', self.gf('django.db.models.fields.CharField')(max_length=255)), 80 | ('apn_service', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ios_notifications.APNService'])), 81 | )) 82 | db.send_create_signal('ios_notifications', ['FeedbackService']) 83 | 84 | # Adding unique constraint on 'FeedbackService', fields ['name', 'hostname'] 85 | db.create_unique('ios_notifications_feedbackservice', ['name', 'hostname']) 86 | 87 | 88 | def backwards(self, orm): 89 | # Removing unique constraint on 'FeedbackService', fields ['name', 'hostname'] 90 | db.delete_unique('ios_notifications_feedbackservice', ['name', 'hostname']) 91 | 92 | # Removing unique constraint on 'Device', fields ['token', 'service'] 93 | db.delete_unique('ios_notifications_device', ['token', 'service_id']) 94 | 95 | # Removing unique constraint on 'APNService', fields ['name', 'hostname'] 96 | db.delete_unique('ios_notifications_apnservice', ['name', 'hostname']) 97 | 98 | # Deleting model 'APNService' 99 | db.delete_table('ios_notifications_apnservice') 100 | 101 | # Deleting model 'Notification' 102 | db.delete_table('ios_notifications_notification') 103 | 104 | # Deleting model 'Device' 105 | db.delete_table('ios_notifications_device') 106 | 107 | # Removing M2M table for field users on 'Device' 108 | db.delete_table('ios_notifications_device_users') 109 | 110 | # Deleting model 'FeedbackService' 111 | db.delete_table('ios_notifications_feedbackservice') 112 | 113 | 114 | models = { 115 | 'auth.group': { 116 | 'Meta': {'object_name': 'Group'}, 117 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 118 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 119 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 120 | }, 121 | 'auth.permission': { 122 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 123 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 124 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 125 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 126 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 127 | }, 128 | user_model_label: { 129 | 'Meta': { 130 | 'object_name': User.__name__, 131 | 'db_table': "'%s'" % User._meta.db_table 132 | }, 133 | User._meta.pk.attname: ( 134 | 'django.db.models.fields.AutoField', [], 135 | {'primary_key': 'True', 136 | 'db_column': "'%s'" % User._meta.pk.column} 137 | ), 138 | }, 139 | 'contenttypes.contenttype': { 140 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 141 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 142 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 143 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 144 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 145 | }, 146 | 'ios_notifications.apnservice': { 147 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'APNService'}, 148 | 'certificate': ('django.db.models.fields.TextField', [], {}), 149 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 150 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 151 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 152 | 'passphrase': ('django_fields.fields.EncryptedCharField', [], {'max_length': '101', 'null': 'True', 'cipher': "'AES'", 'blank': 'True'}), 153 | 'private_key': ('django.db.models.fields.TextField', [], {}) 154 | }, 155 | 'ios_notifications.device': { 156 | 'Meta': {'unique_together': "(('token', 'service'),)", 'object_name': 'Device'}, 157 | 'added_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 158 | 'deactivated_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 159 | 'display': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 160 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 161 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 162 | 'last_notified_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 163 | 'os_version': ('django.db.models.fields.CharField', [], {'max_length': '20', 'null': 'True', 'blank': 'True'}), 164 | 'platform': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 165 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 166 | 'token': ('django.db.models.fields.CharField', [], {'max_length': '64'}), 167 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'ios_devices'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['%s']" % user_orm_label}) 168 | }, 169 | 'ios_notifications.feedbackservice': { 170 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'FeedbackService'}, 171 | 'apn_service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 172 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 173 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 174 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 175 | }, 176 | 'ios_notifications.notification': { 177 | 'Meta': {'object_name': 'Notification'}, 178 | 'badge': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'null': 'True'}), 179 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 180 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 181 | 'last_sent_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 182 | 'message': ('django.db.models.fields.CharField', [], {'max_length': '200'}), 183 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 184 | 'sound': ('django.db.models.fields.CharField', [], {'default': "'default'", 'max_length': '30', 'null': 'True'}) 185 | } 186 | } 187 | 188 | complete_apps = ['ios_notifications'] -------------------------------------------------------------------------------- /ios_notifications/south_migrations/0002_auto__add_field_notification_custom_payload__chg_field_notification_so.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Notification.custom_payload' 12 | db.add_column('ios_notifications_notification', 'custom_payload', 13 | self.gf('django.db.models.fields.CharField')(default='', max_length=240, blank=True), 14 | keep_default=False) 15 | 16 | 17 | # Changing field 'Notification.sound' 18 | if not db.dry_run: 19 | for notification in orm['ios_notifications.notification'].objects.all(): 20 | if notification.sound is None: 21 | notification.sound = '' 22 | notification.save() 23 | db.alter_column('ios_notifications_notification', 'sound', self.gf('django.db.models.fields.CharField')(default='', max_length=30)) 24 | 25 | def backwards(self, orm): 26 | # Deleting field 'Notification.custom_payload' 27 | db.delete_column('ios_notifications_notification', 'custom_payload') 28 | 29 | 30 | # Changing field 'Notification.sound' 31 | db.alter_column('ios_notifications_notification', 'sound', self.gf('django.db.models.fields.CharField')(max_length=30, null=True)) 32 | 33 | models = { 34 | 'auth.group': { 35 | 'Meta': {'object_name': 'Group'}, 36 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 37 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 38 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 39 | }, 40 | 'auth.permission': { 41 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 42 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 43 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 44 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 45 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 46 | }, 47 | 'auth.user': { 48 | 'Meta': {'object_name': 'User'}, 49 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 50 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 51 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 52 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 53 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 54 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 55 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 56 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 57 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 58 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 59 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 60 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 61 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 62 | }, 63 | 'contenttypes.contenttype': { 64 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 65 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 66 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 67 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 68 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 69 | }, 70 | 'ios_notifications.apnservice': { 71 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'APNService'}, 72 | 'certificate': ('django.db.models.fields.TextField', [], {}), 73 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 74 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 75 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 76 | 'passphrase': ('django_fields.fields.EncryptedCharField', [], {'max_length': '101', 'null': 'True', 'cipher': "'AES'", 'blank': 'True'}), 77 | 'private_key': ('django.db.models.fields.TextField', [], {}) 78 | }, 79 | 'ios_notifications.device': { 80 | 'Meta': {'unique_together': "(('token', 'service'),)", 'object_name': 'Device'}, 81 | 'added_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 82 | 'deactivated_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 83 | 'display': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 84 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 85 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 86 | 'last_notified_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 87 | 'os_version': ('django.db.models.fields.CharField', [], {'max_length': '20', 'null': 'True', 'blank': 'True'}), 88 | 'platform': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 89 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 90 | 'token': ('django.db.models.fields.CharField', [], {'max_length': '64'}), 91 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'ios_devices'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['auth.User']"}) 92 | }, 93 | 'ios_notifications.feedbackservice': { 94 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'FeedbackService'}, 95 | 'apn_service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 96 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 97 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 98 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 99 | }, 100 | 'ios_notifications.notification': { 101 | 'Meta': {'object_name': 'Notification'}, 102 | 'badge': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), 103 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 104 | 'custom_payload': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), 105 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 106 | 'last_sent_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 107 | 'message': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}), 108 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ios_notifications.APNService']"}), 109 | 'sound': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}) 110 | } 111 | } 112 | 113 | complete_apps = ['ios_notifications'] 114 | -------------------------------------------------------------------------------- /ios_notifications/south_migrations/0003_auto__add_field_notification_loc_payload.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Notification.loc_payload' 12 | db.add_column(u'ios_notifications_notification', 'loc_payload', 13 | self.gf('django.db.models.fields.CharField')(default='', max_length=240, blank=True), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'Notification.loc_payload' 19 | db.delete_column(u'ios_notifications_notification', 'loc_payload') 20 | 21 | 22 | models = { 23 | 'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 28 | }, 29 | 'auth.permission': { 30 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 33 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | 'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 42 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | 'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | 'ios_notifications.apnservice': { 60 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'APNService'}, 61 | 'certificate': ('django.db.models.fields.TextField', [], {}), 62 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 63 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 64 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 65 | 'passphrase': ('django_fields.fields.EncryptedCharField', [], {'max_length': '110', 'null': 'True', 'block_type': "'MODE_CBC'", 'cipher': "'AES'", 'blank': 'True'}), 66 | 'private_key': ('django.db.models.fields.TextField', [], {}) 67 | }, 68 | 'ios_notifications.device': { 69 | 'Meta': {'unique_together': "(('token', 'service'),)", 'object_name': 'Device'}, 70 | 'added_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 71 | 'deactivated_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 72 | 'display': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 73 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 74 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 75 | 'last_notified_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 76 | 'os_version': ('django.db.models.fields.CharField', [], {'max_length': '20', 'null': 'True', 'blank': 'True'}), 77 | 'platform': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 78 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 79 | 'token': ('django.db.models.fields.CharField', [], {'max_length': '64'}), 80 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'ios_devices'", 'null': 'True', 'symmetrical': 'False', 'to': u"orm['auth.User']"}) 81 | }, 82 | 'ios_notifications.feedbackservice': { 83 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'FeedbackService'}, 84 | 'apn_service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 85 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 86 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 87 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 88 | }, 89 | 'ios_notifications.notification': { 90 | 'Meta': {'object_name': 'Notification'}, 91 | 'badge': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), 92 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 93 | 'custom_payload': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), 94 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 95 | 'last_sent_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 96 | 'loc_payload': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), 97 | 'message': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}), 98 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 99 | 'sound': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}) 100 | } 101 | } 102 | 103 | complete_apps = ['ios_notifications'] 104 | -------------------------------------------------------------------------------- /ios_notifications/south_migrations/0004_auto__add_field_notification_silent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding field 'Notification.silent' 12 | db.add_column(u'ios_notifications_notification', 'silent', 13 | self.gf('django.db.models.fields.NullBooleanField')(null=True, blank=True), 14 | keep_default=False) 15 | 16 | 17 | def backwards(self, orm): 18 | # Deleting field 'Notification.silent' 19 | db.delete_column(u'ios_notifications_notification', 'silent') 20 | 21 | 22 | models = { 23 | u'auth.group': { 24 | 'Meta': {'object_name': 'Group'}, 25 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 26 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 27 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 28 | }, 29 | u'auth.permission': { 30 | 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, 31 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 32 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), 33 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 34 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 35 | }, 36 | u'auth.user': { 37 | 'Meta': {'object_name': 'User'}, 38 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 39 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 40 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 41 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 42 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 43 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 44 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 45 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 46 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 47 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 48 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 49 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 50 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 51 | }, 52 | u'contenttypes.contenttype': { 53 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 54 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 55 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 56 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 58 | }, 59 | u'ios_notifications.apnservice': { 60 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'APNService'}, 61 | 'certificate': ('django.db.models.fields.TextField', [], {}), 62 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 63 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 64 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 65 | 'passphrase': ('django_fields.fields.EncryptedCharField', [], {'max_length': '110', 'null': 'True', 'block_type': "'MODE_CBC'", 'cipher': "'AES'", 'blank': 'True'}), 66 | 'private_key': ('django.db.models.fields.TextField', [], {}) 67 | }, 68 | u'ios_notifications.device': { 69 | 'Meta': {'unique_together': "(('token', 'service'),)", 'object_name': 'Device'}, 70 | 'added_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 71 | 'deactivated_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 72 | 'display': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 73 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 74 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 75 | 'last_notified_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 76 | 'os_version': ('django.db.models.fields.CharField', [], {'max_length': '20', 'null': 'True', 'blank': 'True'}), 77 | 'platform': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}), 78 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 79 | 'token': ('django.db.models.fields.CharField', [], {'max_length': '64'}), 80 | 'users': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'ios_devices'", 'null': 'True', 'symmetrical': 'False', 'to': u"orm['auth.User']"}) 81 | }, 82 | u'ios_notifications.feedbackservice': { 83 | 'Meta': {'unique_together': "(('name', 'hostname'),)", 'object_name': 'FeedbackService'}, 84 | 'apn_service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 85 | 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255'}), 86 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 87 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) 88 | }, 89 | u'ios_notifications.notification': { 90 | 'Meta': {'object_name': 'Notification'}, 91 | 'badge': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), 92 | 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 93 | 'custom_payload': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), 94 | u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 95 | 'last_sent_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 96 | 'loc_payload': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), 97 | 'message': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}), 98 | 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['ios_notifications.APNService']"}), 99 | 'silent': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}), 100 | 'sound': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}) 101 | } 102 | } 103 | 104 | complete_apps = ['ios_notifications'] -------------------------------------------------------------------------------- /ios_notifications/south_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenmuss/django-ios-notifications/9600c496751668ae2bee70a13dbd157d984596e1/ios_notifications/south_migrations/__init__.py -------------------------------------------------------------------------------- /ios_notifications/templates/admin/ios_notifications/notification/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% block extrastyle %} 3 | {{ block.super }} 4 | 9 | {% endblock %} 10 | {% block content %} 11 | {{ block.super }} 12 | {% with notification=adminform.form.instance %} 13 | {% if notification and notification.id %} 14 |
15 |

Push this notification to devices

16 |

Use the button below to push this notification to all devices recognised by the notification's APN Service

17 |
18 | {% csrf_token %} 19 | 20 |
21 |
22 | 27 | {% endif %} 28 | {% endwith %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /ios_notifications/templates/admin/ios_notifications/notification/push_notification.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %}{% load i18n %} 2 | {% block breadcrumbs %} 3 | 9 | {% endblock %} 10 | {% block content %} 11 |
12 |

The notification was{% if not sent %} not{% endif %} successfully pushed

13 |
14 | The message: 15 |
{{ notification.message }}
16 | {% if sent %}was sent to {{ num_devices }} device{% ifnotequal num_devices 1 %}s{% endifnotequal %}{% endif %} 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /ios_notifications/test.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICoTCCAYmgAwIBAwIBATANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDEwkxMjcu 3 | MC4wLjEwHhcNMTIwNzA5MDA0NzAyWhcNMTIwNzEwMDA0NzAyWjAUMRIwEAYDVQQD 4 | EwkxMjcuMC4wLjEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqlqQf 5 | Ykdi9tGANFfby8jJ+3K94mxAjVTECBjaY4N4NLisemIBSUliN/LE+Z7+XUzw3JtW 6 | XPSfOvaJue/lj8G0f+nKTEkXteNTrnHAUM5nGoRctwMTeVPEFaSGOU+wIkN+H8FX 7 | YCfvTrDLVjeV0RDGSiXDEEQTPNid9MR7HXHDawcfNXNAwhTL+w3BXnzuHBsObAi1 8 | bhBLLCJ3zuex/JoAVDOnpbMv9hEdhrXG1++G+jePH+2CY4m5+tEkAjsEhNl851bp 9 | fdofHUIT3s+0udwTehGqbHmcWkPdTaBmHkIP9tRJ5Ab6Ug5ctInzE/FDfP6w2CD+ 10 | OQ6N5Hnz6B1p8DznAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAHUdDyJv42HXzVmI 11 | 6bDF+p6K/NBgHyZ/K5tITmcaLJNpt99zRyA4EbtDrnYkY9TQNHh9Wx16LstmoG7n 12 | x2iW2AewDS5oA5OfDk76JJ0ee8JrZGy7llJunWFDoEIih7vSnPHruk3woqE7ai2b 13 | R7n4kk+tw2RIo2vHCx6cDRkiq7b8CIPQXyM7MsJmM97bPkAZHEvariAOTIFTaovV 14 | KQpsPNRlPkqNUDi+myFYyVhEhH7DYeeV1gWQlfc1m7xsa56VSHpGxndft9/jECY2 15 | b1i0F/E+ReU5a/Fg3DYee/hcaIoO0DHk6fp448vejlI4dEtLjUnj/sZB1RzA2xa2 16 | y9BO2Rc= 17 | -----END CERTIFICATE----- 18 | -----BEGIN RSA PRIVATE KEY----- 19 | MIIEpAIBAAKCAQEAqpakH2JHYvbRgDRX28vIyftyveJsQI1UxAgY2mODeDS4rHpi 20 | AUlJYjfyxPme/l1M8NybVlz0nzr2ibnv5Y/BtH/pykxJF7XjU65xwFDOZxqEXLcD 21 | E3lTxBWkhjlPsCJDfh/BV2An706wy1Y3ldEQxkolwxBEEzzYnfTEex1xw2sHHzVz 22 | QMIUy/sNwV587hwbDmwItW4QSywid87nsfyaAFQzp6WzL/YRHYa1xtfvhvo3jx/t 23 | gmOJufrRJAI7BITZfOdW6X3aHx1CE97PtLncE3oRqmx5nFpD3U2gZh5CD/bUSeQG 24 | +lIOXLSJ8xPxQ3z+sNgg/jkOjeR58+gdafA85wIDAQABAoIBAQCMvZhO5FCtT6Ft 25 | OsI57xmLu07hZsuVPoVu7pdCptOy+xxaAOaW1RYcWLiM1r3ccrGmDvyB9lNEg+sf 26 | mi5YoZBZESeb5fBwBXq2cbgbyQ9hdTk7HSsGiBUaNBj3PJWIZdx1VFG5evW3tJ6c 27 | RFe73S8PyeD53JOto4e8WlM4mARiCrQkiu4xodbn7sPb0h4ZiVi7eauTvr7YcWpW 28 | oLBgk72+KKPwcvgnav0uPxIUSr1CK71m2Hw6gxLL/RjtAb8uGm1rm6ZqJ1CHsZ6J 29 | tJ9joakm8H6qLqKw6IuPzYA0Gq7YQ/PT6/QGimnCz476wQjpGd/0JywNIkrdYqG2 30 | i7Bqwi5xAoGBANu8FgXYGq/L0odVJ63jO8ktjtFcANvK2q3RJX7l6JoTETw9Igxz 31 | k1/Z79K+eJyR9T28lhFswBIM5K/i5y8H/ael74N0Hq3H+lRjmMS09iSCvHNaVTX7 32 | ebUiuJSxoInfJDMEeulAywDBHBYYJrYO+RLwv5lkCDzM1muccVriMfZbAoGBAMa+ 33 | GQqpR/OsmoQx1igtgfyAB7WCpP6unxIwpGU4psfGXqdqQRlPuv1tsDS6AFcvfxyZ 34 | nDS4loRSa1bGFod0xRtLGlJ9I0b49HsdRxHmo0MqxLtN6pC9A4jYyLDUuFa/gq5W 35 | 2C5Bb98BCGreD5i7WmS4J8FVqRGdsJb6CyWy+hFlAoGAPTPgNnSAymJNG2C+kpJu 36 | PpSv6ORlYNLZofxVI0lKRk/1RwAIEcvHSrVbNSnUUlfdJPr4GZZe0ShCMjNTDSh+ 37 | oEl5svWO7fx7XzH2hSOaQ4UelEqe3VBUD/3Bx7jJ7Fz4qjUfPwTLBkTDW+wSLDdz 38 | bLEdzM2t9bFgL8z9TcEfBW0CgYEAgf06Qcfg4NdHJSm3igXh3DYdVLIDmvS55Fre 39 | W7pHE6mCpXuQ4q5Mfo/szT/PEzdkq18pVS5afGev/0yG1cghV62ypLtmhHg26AOJ 40 | RYMVy8vAa0YWIt8N3cb01Pv9KfgO0FrLAM4aDsENMWDW0K3R/MiacBDICVabdtRK 41 | 0DiU6SUCgYBjmmnTGTHTlwAecBDoIcfdkYZp0EGiWXk0Xv18JgPjqxbdLVaaU2wz 42 | OR2haclFeIIlBTGIxMvRNIwIn8ABThuMT1oMLPs5VbHN3J25iqE5azlfB66igFb6 43 | 7J5nm44CKFCB9EE/WRx4cW9i1j69xe/Uj3p8h7rTe7EuvBq2U206bQ== 44 | -----END RSA PRIVATE KEY----- 45 | -------------------------------------------------------------------------------- /ios_notifications/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import subprocess 3 | import struct 4 | import os 5 | import json 6 | import uuid 7 | import StringIO 8 | 9 | import django 10 | from django.test import TestCase 11 | 12 | try: 13 | from django.test.utils import override_settings 14 | except ImportError: 15 | from override_settings import override_settings 16 | 17 | from django.conf import settings 18 | from django.core.urlresolvers import reverse 19 | from django.contrib.auth.models import User 20 | from django.http import HttpResponseNotAllowed 21 | from django.core import management 22 | 23 | try: 24 | from django.utils.timezone import now as dt_now 25 | except ImportError: 26 | import datetime 27 | dt_now = datetime.datetime.now 28 | 29 | from .models import APNService, Device, Notification, NotificationPayloadSizeExceeded 30 | from .http import JSONResponse 31 | from .utils import generate_cert_and_pkey 32 | from .forms import APNServiceForm 33 | from .settings import get_setting 34 | 35 | TOKEN = '0fd12510cfe6b0a4a89dc7369c96df956f991e66131dab63398734e8000d0029' 36 | TEST_PEM = os.path.abspath(os.path.join(os.path.dirname(__file__), 'test.pem')) 37 | 38 | SSL_SERVER_COMMAND = ('openssl', 's_server', '-accept', '2195', '-cert', TEST_PEM) 39 | 40 | 41 | class UseMockSSLServerMixin(object): 42 | @classmethod 43 | def setUpClass(cls): 44 | super(UseMockSSLServerMixin, cls).setUpClass() 45 | cls.test_server_proc = subprocess.Popen(SSL_SERVER_COMMAND, stdout=subprocess.PIPE) 46 | 47 | @classmethod 48 | def tearDownClass(cls): 49 | cls.test_server_proc.kill() 50 | super(UseMockSSLServerMixin, cls).tearDownClass() 51 | 52 | 53 | class APNServiceTest(UseMockSSLServerMixin, TestCase): 54 | def setUp(self): 55 | cert, key = generate_cert_and_pkey() 56 | self.service = APNService.objects.create(name='test-service', hostname='127.0.0.1', 57 | certificate=cert, private_key=key) 58 | 59 | self.device = Device.objects.create(token=TOKEN, service=self.service) 60 | self.notification = Notification.objects.create(message='Test message', service=self.service) 61 | 62 | def test_invalid_payload_size(self): 63 | n = Notification(message='.' * 250) 64 | self.assertRaises(NotificationPayloadSizeExceeded, self.service.pack_message, n.payload, self.device) 65 | 66 | def test_payload_packed_correctly(self): 67 | fmt = self.service.fmt 68 | payload = self.notification.payload 69 | msg = self.service.pack_message(payload, self.device) 70 | unpacked = struct.unpack(fmt % len(payload), msg) 71 | self.assertEqual(unpacked[-1], payload) 72 | 73 | def test_pack_message_with_invalid_device(self): 74 | self.assertRaises(TypeError, self.service.pack_message, None) 75 | 76 | def test_can_connect_and_push_notification(self): 77 | self.assertIsNone(self.notification.last_sent_at) 78 | self.assertIsNone(self.device.last_notified_at) 79 | self.service.push_notification_to_devices(self.notification, [self.device]) 80 | self.assertIsNotNone(self.notification.last_sent_at) 81 | self.device = Device.objects.get(pk=self.device.pk) # Refresh the object with values from db 82 | self.assertIsNotNone(self.device.last_notified_at) 83 | 84 | def test_create_with_passphrase(self): 85 | cert, key = generate_cert_and_pkey(as_string=True, passphrase='pass') 86 | form = APNServiceForm({'name': 'test', 'hostname': 'localhost', 'certificate': cert, 'private_key': key, 'passphrase': 'pass'}) 87 | self.assertTrue(form.is_valid()) 88 | 89 | def test_create_with_invalid_passphrase(self): 90 | cert, key = generate_cert_and_pkey(as_string=True, passphrase='correct') 91 | form = APNServiceForm({'name': 'test', 'hostname': 'localhost', 'certificate': cert, 'private_key': key, 'passphrase': 'incorrect'}) 92 | self.assertFalse(form.is_valid()) 93 | self.assertTrue('passphrase' in form.errors) 94 | 95 | def test_pushing_notification_in_chunks(self): 96 | devices = [] 97 | for i in xrange(10): 98 | token = uuid.uuid1().get_hex() * 2 99 | device = Device.objects.create(token=token, service=self.service) 100 | devices.append(device) 101 | 102 | started_at = dt_now() 103 | self.service.push_notification_to_devices(self.notification, devices, chunk_size=2) 104 | device_count = len(devices) 105 | self.assertEquals(device_count, 106 | Device.objects.filter(last_notified_at__gte=started_at).count()) 107 | 108 | 109 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthNone') 110 | class APITest(UseMockSSLServerMixin, TestCase): 111 | urls = 'ios_notifications.urls' 112 | 113 | def setUp(self): 114 | cert, key = generate_cert_and_pkey() 115 | self.service = APNService.objects.create(name='test-service', hostname='127.0.0.1', 116 | certificate=cert, private_key=key) 117 | self.device_token = TOKEN 118 | self.user = User.objects.create(username='testuser', email='test@example.com') 119 | self.device = Device.objects.create(service=self.service, token='0fd12510cfe6b0a4a89dc7369d96df956f991e66131dab63398734e8000d0029') 120 | 121 | def test_register_device_invalid_params(self): 122 | """ 123 | Test that sending a POST request to the device API 124 | without POST parameters `token` and `service` results 125 | in a 400 bad request response. 126 | """ 127 | resp = self.client.post(reverse('ios-notifications-device-create')) 128 | self.assertEqual(resp.status_code, 400) 129 | self.assertTrue(isinstance(resp, JSONResponse)) 130 | content = json.loads(resp.content) 131 | keys = content.keys() 132 | self.assertTrue('token' in keys and 'service' in keys) 133 | 134 | def test_register_device(self): 135 | """ 136 | Test a device is created when calling the API with the correct 137 | POST parameters. 138 | """ 139 | resp = self.client.post(reverse('ios-notifications-device-create'), 140 | {'token': self.device_token, 141 | 'service': self.service.id}) 142 | 143 | self.assertEqual(resp.status_code, 201) 144 | self.assertTrue(isinstance(resp, JSONResponse)) 145 | content = resp.content 146 | device_json = json.loads(content) 147 | self.assertEqual(device_json.get('model'), 'ios_notifications.device') 148 | 149 | def test_disallowed_method(self): 150 | resp = self.client.delete(reverse('ios-notifications-device-create')) 151 | self.assertEqual(resp.status_code, 405) 152 | self.assertTrue(isinstance(resp, HttpResponseNotAllowed)) 153 | 154 | def test_update_device(self): 155 | kwargs = {'token': self.device.token, 'service__id': self.device.service.id} 156 | url = reverse('ios-notifications-device', kwargs=kwargs) 157 | resp = self.client.put(url, 'users=%d&platform=iPhone' % self.user.id, 158 | content_type='application/x-www-form-urlencode') 159 | self.assertEqual(resp.status_code, 200) 160 | self.assertTrue(isinstance(resp, JSONResponse)) 161 | device_json = json.loads(resp.content) 162 | self.assertEqual(device_json.get('pk'), self.device.id) 163 | self.assertTrue(self.user in self.device.users.all()) 164 | 165 | def test_get_device_details(self): 166 | kwargs = {'token': self.device.token, 'service__id': self.device.service.id} 167 | url = reverse('ios-notifications-device', kwargs=kwargs) 168 | resp = self.client.get(url) 169 | self.assertEqual(resp.status_code, 200) 170 | content = resp.content 171 | device_json = json.loads(content) 172 | self.assertEqual(device_json.get('model'), 'ios_notifications.device') 173 | 174 | 175 | class AuthenticationDecoratorTestAuthBasic(UseMockSSLServerMixin, TestCase): 176 | urls = 'ios_notifications.urls' 177 | 178 | def setUp(self): 179 | cert, key = generate_cert_and_pkey() 180 | self.service = APNService.objects.create(name='test-service', hostname='127.0.0.1', 181 | certificate=cert, private_key=key) 182 | self.device_token = TOKEN 183 | self.user_password = 'abc123' 184 | self.user = User.objects.create(username='testuser', email='test@example.com') 185 | self.user.set_password(self.user_password) 186 | self.user.is_staff = True 187 | self.user.save() 188 | self.device = Device.objects.create(service=self.service, token='0fd12510cfe6b0a4a89dc7369d96df956f991e66131dab63398734e8000d0029') 189 | 190 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthBasic') 191 | def test_basic_authorization_request(self): 192 | kwargs = {'token': self.device.token, 'service__id': self.device.service.id} 193 | url = reverse('ios-notifications-device', kwargs=kwargs) 194 | user_pass = '%s:%s' % (self.user.username, self.user_password) 195 | auth_header = 'Basic %s' % user_pass.encode('base64') 196 | resp = self.client.get(url, {}, HTTP_AUTHORIZATION=auth_header) 197 | self.assertEquals(resp.status_code, 200) 198 | 199 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthBasic') 200 | def test_basic_authorization_request_invalid_credentials(self): 201 | user_pass = '%s:%s' % (self.user.username, 'invalidpassword') 202 | auth_header = 'Basic %s' % user_pass.encode('base64') 203 | url = reverse('ios-notifications-device-create') 204 | resp = self.client.get(url, HTTP_AUTHORIZATION=auth_header) 205 | self.assertEquals(resp.status_code, 401) 206 | self.assertTrue('authentication error' in resp.content) 207 | 208 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthBasic') 209 | def test_basic_authorization_missing_header(self): 210 | url = reverse('ios-notifications-device-create') 211 | resp = self.client.get(url) 212 | self.assertEquals(resp.status_code, 401) 213 | self.assertTrue('Authorization header not set' in resp.content) 214 | 215 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthDoesNotExist') 216 | def test_invalid_authentication_type(self): 217 | from ios_notifications.decorators import InvalidAuthenticationType 218 | url = reverse('ios-notifications-device-create') 219 | self.assertRaises(InvalidAuthenticationType, self.client.get, url) 220 | 221 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION=None) 222 | def test_no_authentication_type(self): 223 | from ios_notifications.decorators import InvalidAuthenticationType 224 | url = reverse('ios-notifications-device-create') 225 | self.assertRaises(InvalidAuthenticationType, self.client.get, url) 226 | 227 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthBasicIsStaff') 228 | def test_basic_authorization_is_staff(self): 229 | kwargs = {'token': self.device.token, 'service__id': self.device.service.id} 230 | url = reverse('ios-notifications-device', kwargs=kwargs) 231 | user_pass = '%s:%s' % (self.user.username, self.user_password) 232 | auth_header = 'Basic %s' % user_pass.encode('base64') 233 | self.user.is_staff = True 234 | resp = self.client.get(url, HTTP_AUTHORIZATION=auth_header) 235 | self.assertEquals(resp.status_code, 200) 236 | 237 | @override_settings(IOS_NOTIFICATIONS_AUTHENTICATION='AuthBasicIsStaff') 238 | def test_basic_authorization_is_staff_with_non_staff_user(self): 239 | kwargs = {'token': self.device.token, 'service__id': self.device.service.id} 240 | url = reverse('ios-notifications-device', kwargs=kwargs) 241 | user_pass = '%s:%s' % (self.user.username, self.user_password) 242 | auth_header = 'Basic %s' % user_pass.encode('base64') 243 | self.user.is_staff = False 244 | self.user.save() 245 | resp = self.client.get(url, HTTP_AUTHORIZATION=auth_header) 246 | self.assertEquals(resp.status_code, 401) 247 | self.assertTrue('authentication error' in resp.content) 248 | 249 | 250 | class NotificationTest(UseMockSSLServerMixin, TestCase): 251 | def setUp(self): 252 | cert, key = generate_cert_and_pkey() 253 | self.service = APNService.objects.create(name='service', hostname='127.0.0.1', 254 | private_key=key, certificate=cert) 255 | self.service.PORT = 2195 # For ease of use simply change port to default port in test_server 256 | self.custom_payload = json.dumps({"." * 10: "." * 50}) 257 | self.notification = Notification.objects.create(service=self.service, message='Test message', custom_payload=self.custom_payload) 258 | 259 | def test_valid_length(self): 260 | self.notification.message = 'test message' 261 | self.assertTrue(self.notification.is_valid_length()) 262 | 263 | def test_invalid_length(self): 264 | self.notification.message = '.' * 250 265 | self.assertFalse(self.notification.is_valid_length()) 266 | 267 | def test_invalid_length_with_custom_payload(self): 268 | self.notification.message = '.' * 100 269 | self.notification.custom_payload = '{"%s":"%s"}' % ("." * 20, "." * 120) 270 | self.assertFalse(self.notification.is_valid_length()) 271 | 272 | def test_extra_property_with_custom_payload(self): 273 | custom_payload = {"." * 10: "." * 50, "nested": {"+" * 10: "+" * 50}} 274 | self.notification.extra = custom_payload 275 | self.assertEqual(self.notification.custom_payload, json.dumps(custom_payload)) 276 | self.assertEqual(self.notification.extra, custom_payload) 277 | self.assertTrue(self.notification.is_valid_length()) 278 | 279 | def test_loc_data_payload(self): 280 | self.notification.set_loc_data('TEST_1', [1, 'ab', 1.2, 'CD']) 281 | self.notification.message = 'test message' 282 | loc_data = {'loc-key': 'TEST_1', 'loc-args': ['1', 'ab', '1.2', 'CD']} 283 | self.assertEqual(self.notification.loc_data, loc_data) 284 | self.assertTrue(self.notification.is_valid_length()) 285 | p = self.notification.payload 286 | self.assertEqual(json.loads(p)['aps']['alert'], loc_data) 287 | 288 | def test_extra_property_not_dict(self): 289 | with self.assertRaises(TypeError): 290 | self.notification.extra = 111 291 | 292 | def test_extra_property_none(self): 293 | self.notification.extra = None 294 | self.assertEqual(self.notification.extra, None) 295 | self.assertEqual(self.notification.custom_payload, '') 296 | self.assertTrue(self.notification.is_valid_length()) 297 | 298 | def test_push_to_all_devices_persist_existing(self): 299 | self.assertIsNone(self.notification.last_sent_at) 300 | self.notification.persist = False 301 | self.notification.push_to_all_devices() 302 | self.assertIsNotNone(self.notification.last_sent_at) 303 | 304 | def test_push_to_all_devices_persist_new(self): 305 | notification = Notification(service=self.service, message='Test message (new)') 306 | notification.persist = True 307 | notification.push_to_all_devices() 308 | self.assertIsNotNone(notification.last_sent_at) 309 | self.assertIsNotNone(notification.pk) 310 | 311 | def test_push_to_all_devices_no_persist(self): 312 | notification = Notification(service=self.service, message='Test message (new)') 313 | notification.persist = False 314 | notification.push_to_all_devices() 315 | self.assertIsNone(notification.last_sent_at) 316 | self.assertIsNone(notification.pk) 317 | 318 | 319 | class ManagementCommandPushNotificationTest(UseMockSSLServerMixin, TestCase): 320 | def setUp(self): 321 | self.started_at = dt_now() 322 | cert, key = generate_cert_and_pkey() 323 | self.service = APNService.objects.create(name='service', hostname='127.0.0.1', 324 | private_key=key, certificate=cert) 325 | self.service.PORT = 2195 326 | self.device = Device.objects.create(token=TOKEN, service=self.service) 327 | 328 | def test_call_push_ios_notification_command_explicit_persist(self): 329 | msg = 'some message' 330 | management.call_command('push_ios_notification', **{'message': msg, 'service': self.service.id, 'verbosity': 0, 'persist': True}) 331 | self.assertTrue(Notification.objects.filter(message=msg, last_sent_at__gt=self.started_at).exists()) 332 | self.assertTrue(self.device in Device.objects.filter(last_notified_at__gt=self.started_at)) 333 | 334 | def test_call_push_ios_notification_command_explicit_no_persist(self): 335 | msg = 'some message' 336 | management.call_command('push_ios_notification', **{'message': msg, 'service': self.service.id, 'verbosity': 0, 'persist': False}) 337 | self.assertFalse(Notification.objects.filter(message=msg, last_sent_at__gt=self.started_at).exists()) 338 | self.assertTrue(self.device in Device.objects.filter(last_notified_at__gt=self.started_at)) 339 | 340 | @override_settings(IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS=True) 341 | def test_call_push_ios_notification_command_default_persist(self): 342 | msg = 'some message' 343 | management.call_command('push_ios_notification', **{'message': msg, 'service': self.service.id, 'verbosity': 0}) 344 | self.assertTrue(Notification.objects.filter(message=msg, last_sent_at__gt=self.started_at).exists()) 345 | self.assertTrue(self.device in Device.objects.filter(last_notified_at__gt=self.started_at)) 346 | 347 | @override_settings(IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS=False) 348 | def test_call_push_ios_notification_command_default_no_persist(self): 349 | msg = 'some message' 350 | management.call_command('push_ios_notification', **{'message': msg, 'service': self.service.id, 'verbosity': 0}) 351 | self.assertFalse(Notification.objects.filter(message=msg, last_sent_at__gt=self.started_at).exists()) 352 | self.assertTrue(self.device in Device.objects.filter(last_notified_at__gt=self.started_at)) 353 | 354 | @override_settings() 355 | def test_call_push_ios_notification_command_default_persist_not_specified(self): 356 | try: 357 | # making sure that IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS is not specified in app settings, otherwise this test means nothing 358 | del settings.IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS 359 | except AttributeError: 360 | pass 361 | msg = 'some message' 362 | management.call_command('push_ios_notification', **{'message': msg, 'service': self.service.id, 'verbosity': 0}) 363 | self.assertTrue(Notification.objects.filter(message=msg, last_sent_at__gt=self.started_at).exists()) 364 | self.assertTrue(self.device in Device.objects.filter(last_notified_at__gt=self.started_at)) 365 | 366 | def test_either_message_or_extra_option_required(self): 367 | # In Django < 1.5 django.core.management.base.BaseCommand.execute 368 | # catches CommandError and raises SystemExit instead. 369 | exception = SystemExit if django.VERSION < (1, 5) else management.base.CommandError 370 | 371 | with self.assertRaises(exception): 372 | management.call_command('push_ios_notification', service=self.service.pk, 373 | verbosity=0, stderr=StringIO.StringIO()) 374 | 375 | 376 | class ManagementCommandCallFeedbackService(TestCase): 377 | pass 378 | 379 | 380 | class DefaultSettings(TestCase): 381 | def test_persist_notifications_setting(self): 382 | self.assertEqual(True, get_setting('IOS_NOTIFICATIONS_PERSIST_NOTIFICATIONS')) 383 | 384 | def test_authentication_setting(self): 385 | self.assertEqual(None, get_setting('IOS_NOTIFICATIONS_AUTHENTICATION')) 386 | 387 | def test_auth_user_model(self): 388 | self.assertEqual('auth.User', get_setting('AUTH_USER_MODEL')) 389 | 390 | def test_invalid_setting(self): 391 | setting_name = '_THIS_SETTING_SHOULD_NOT_EXIST__________' 392 | with self.assertRaises(KeyError): 393 | get_setting(setting_name) 394 | -------------------------------------------------------------------------------- /ios_notifications/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.conf.urls import url 4 | from .api import routes 5 | 6 | urlpatterns = [ 7 | url(r'^device/$', routes.device, name='ios-notifications-device-create'), 8 | url(r'^device/(?P\w+)/(?P\d+)/$', routes.device, name='ios-notifications-device'), 9 | ] 10 | -------------------------------------------------------------------------------- /ios_notifications/utils.py: -------------------------------------------------------------------------------- 1 | import OpenSSL 2 | 3 | 4 | def generate_cert_and_pkey(as_string=True, passphrase=None): 5 | key = OpenSSL.crypto.PKey() 6 | key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) 7 | cert = OpenSSL.crypto.X509() 8 | cert.set_version(3) 9 | cert.set_serial_number(1) 10 | cert.get_subject().CN = '127.0.0.1' 11 | cert.gmtime_adj_notBefore(0) 12 | cert.gmtime_adj_notAfter(24 * 60 * 60) 13 | cert.set_issuer(cert.get_subject()) 14 | cert.set_pubkey(key) 15 | cert.sign(key, 'sha1') 16 | if as_string: 17 | args = [OpenSSL.crypto.FILETYPE_PEM, key] 18 | if passphrase is not None: 19 | args += ['DES3', passphrase] 20 | cert = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) 21 | key = OpenSSL.crypto.dump_privatekey(*args) 22 | return cert, key 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-fields==0.3.0 2 | pyOpenSSL==0.13.1 3 | pycrypto==2.6 4 | wsgiref==0.1.2 5 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pushd . 4 | cd test/testapp 5 | python manage.py test ios_notifications 6 | STATUS=$? 7 | popd 8 | exit $STATUS 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import ios_notifications 3 | import os 4 | 5 | setup( 6 | author='Stephen Muss', 7 | author_email='stephenmuss@gmail.com', 8 | name='django-ios-notifications', 9 | version=ios_notifications.VERSION, 10 | description='Django iOS Notifications makes it easy to send push notifications to iOS devices', 11 | long_description=open(os.path.join(os.path.dirname(__file__), 'README.md')).read(), 12 | url='https://github.com/stephenmuss/django-ios-notifications', 13 | download_url='https://github.com/stephenmuss/django-ios-notifications/zipball/v0.2.0', 14 | license='BSD License', 15 | packages=find_packages(), 16 | include_package_data=True, 17 | classifiers=[ 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Operating System :: OS Independent', 23 | 'Programming Language :: Python', 24 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 25 | ], 26 | install_requires=[ 27 | 'Django>=1.3', 28 | 'pyOpenSSL>=0.10', 29 | 'django-fields>=0.3.0' 30 | ], 31 | dependency_links=[ 32 | 'https://github.com/nautilebleu/django-fields/tarball/master#egg=django-fields-0.3.0', 33 | ], 34 | zip_safe=False 35 | ) 36 | -------------------------------------------------------------------------------- /test/testapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | sys.path.insert(0, os.path.abspath('./../../')) 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /test/testapp/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenmuss/django-ios-notifications/9600c496751668ae2bee70a13dbd157d984596e1/test/testapp/testapp/__init__.py -------------------------------------------------------------------------------- /test/testapp/testapp/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for testapp project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@example.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'NAME': 'test.db', 16 | # The following settings are not used with sqlite3: 17 | 'USER': '', 18 | 'PASSWORD': '', 19 | 'HOST': '', # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. 20 | 'PORT': '', # Set to empty string for default. 21 | } 22 | } 23 | 24 | # Hosts/domain names that are valid for this site; required if DEBUG is False 25 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts 26 | ALLOWED_HOSTS = [] 27 | 28 | # Local time zone for this installation. Choices can be found here: 29 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 30 | # although not all choices may be available on all operating systems. 31 | # In a Windows environment this must be set to your system time zone. 32 | TIME_ZONE = 'America/Chicago' 33 | 34 | # Language code for this installation. All choices can be found here: 35 | # http://www.i18nguy.com/unicode/language-identifiers.html 36 | LANGUAGE_CODE = 'en-us' 37 | 38 | SITE_ID = 1 39 | 40 | # If you set this to False, Django will make some optimizations so as not 41 | # to load the internationalization machinery. 42 | USE_I18N = True 43 | 44 | # If you set this to False, Django will not format dates, numbers and 45 | # calendars according to the current locale. 46 | USE_L10N = True 47 | 48 | # If you set this to False, Django will not use timezone-aware datetimes. 49 | USE_TZ = True 50 | 51 | # Absolute filesystem path to the directory that will hold user-uploaded files. 52 | # Example: "/var/www/example.com/media/" 53 | MEDIA_ROOT = '' 54 | 55 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 56 | # trailing slash. 57 | # Examples: "http://example.com/media/", "http://media.example.com/" 58 | MEDIA_URL = '' 59 | 60 | # Absolute path to the directory static files should be collected to. 61 | # Don't put anything in this directory yourself; store your static files 62 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 63 | # Example: "/var/www/example.com/static/" 64 | STATIC_ROOT = '' 65 | 66 | # URL prefix for static files. 67 | # Example: "http://example.com/static/", "http://static.example.com/" 68 | STATIC_URL = '/static/' 69 | 70 | # Additional locations of static files 71 | STATICFILES_DIRS = ( 72 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 73 | # Always use forward slashes, even on Windows. 74 | # Don't forget to use absolute paths, not relative paths. 75 | ) 76 | 77 | # List of finder classes that know how to find static files in 78 | # various locations. 79 | STATICFILES_FINDERS = ( 80 | 'django.contrib.staticfiles.finders.FileSystemFinder', 81 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 82 | ) 83 | 84 | # Make this unique, and don't share it with anybody. 85 | SECRET_KEY = 'c8+4^x2s-j3_ucbbh@r2#&)anj&k3#(u(w-)k&7&t)k&3b03#u' 86 | 87 | # List of callables that know how to import templates from various sources. 88 | TEMPLATE_LOADERS = ( 89 | 'django.template.loaders.filesystem.Loader', 90 | 'django.template.loaders.app_directories.Loader', 91 | ) 92 | 93 | MIDDLEWARE_CLASSES = ( 94 | 'django.middleware.common.CommonMiddleware', 95 | 'django.contrib.sessions.middleware.SessionMiddleware', 96 | 'django.middleware.csrf.CsrfViewMiddleware', 97 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 98 | 'django.contrib.messages.middleware.MessageMiddleware', 99 | # Uncomment the next line for simple clickjacking protection: 100 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 101 | ) 102 | 103 | ROOT_URLCONF = 'testapp.urls' 104 | 105 | # Python dotted path to the WSGI application used by Django's runserver. 106 | WSGI_APPLICATION = 'testapp.wsgi.application' 107 | 108 | TEMPLATE_DIRS = ( 109 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 110 | # Always use forward slashes, even on Windows. 111 | # Don't forget to use absolute paths, not relative paths. 112 | ) 113 | 114 | INSTALLED_APPS = ( 115 | 'django.contrib.auth', 116 | 'django.contrib.contenttypes', 117 | 'django.contrib.sessions', 118 | 'django.contrib.sites', 119 | 'django.contrib.messages', 120 | 'django.contrib.staticfiles', 121 | 'ios_notifications', 122 | # Uncomment the next line to enable the admin: 123 | 'django.contrib.admin', 124 | # Uncomment the next line to enable admin documentation: 125 | # 'django.contrib.admindocs', 126 | ) 127 | 128 | SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' 129 | 130 | # Setup South Migrations for Backwards Compatibility 131 | SOUTH_MIGRATION_MODULES = { 132 | 'ios_notifications': 'ios_notifications.south_migrations', 133 | } 134 | 135 | # A sample logging configuration. The only tangible logging 136 | # performed by this configuration is to send an email to 137 | # the site admins on every HTTP 500 error when DEBUG=False. 138 | # See http://docs.djangoproject.com/en/dev/topics/logging for 139 | # more details on how to customize your logging configuration. 140 | LOGGING = { 141 | 'version': 1, 142 | 'disable_existing_loggers': False, 143 | 'loggers': {} 144 | } 145 | -------------------------------------------------------------------------------- /test/testapp/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | from django.contrib import admin 5 | admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | url(r'^ios-notifications/', include('ios_notifications.urls')), 9 | # Examples: 10 | # url(r'^$', 'testapp.views.home', name='home'), 11 | # url(r'^testapp/', include('testapp.foo.urls')), 12 | 13 | # Uncomment the admin/doc line below to enable admin documentation: 14 | # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 15 | 16 | # Uncomment the next line to enable the admin: 17 | url(r'^admin/', include(admin.site.urls)), 18 | ) 19 | -------------------------------------------------------------------------------- /test/testapp/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testapp project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "testapp.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | --------------------------------------------------------------------------------