├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── ACKNOWLEDGEMENTS.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── APNS.rst ├── FCM.rst └── WebPush.rst ├── push_notifications ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ └── rest_framework.py ├── apns.py ├── apns_async.py ├── compat.py ├── conf │ ├── __init__.py │ ├── app.py │ ├── appmodel.py │ ├── base.py │ └── legacy.py ├── exceptions.py ├── fields.py ├── gcm.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20160106_0850.py │ ├── 0003_wnsdevice.py │ ├── 0004_fcm.py │ ├── 0005_applicationid.py │ ├── 0006_webpushdevice.py │ ├── 0007_uniquesetting.py │ ├── 0008_webpush_add_edge.py │ ├── 0009_alter_apnsdevice_device_id.py │ ├── 0010_alter_gcmdevice_options_and_more.py │ ├── 0011_alter_apnsdevice_registration_id.py │ └── __init__.py ├── models.py ├── settings.py ├── webpush.py └── wns.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── responses.py ├── settings.py ├── settings_unique.py ├── test_admin.py ├── test_apns_async_models.py ├── test_apns_async_push_payload.py ├── test_apns_models.py ├── test_apns_push_payload.py ├── test_app_config.py ├── test_data │ ├── good_revoked.pem │ ├── good_with_passwd.pem │ └── without_private.pem ├── test_dict_to_message.py ├── test_fields.py ├── test_gcm_push_payload.py ├── test_legacy_config.py ├── test_models.py ├── test_rest_framework.py ├── test_webpush.py ├── test_wns.py └── tst_unique.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = tab 9 | quote_type = double 10 | insert_final_newline = true 11 | tab_width = 4 12 | trim_trailing_whitespace = true 13 | 14 | [*.py] 15 | spaces_around_brackets = none 16 | spaces_around_operators = true 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: 8 | - "dependencies" 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-push-notifications' 11 | runs-on: ubuntu-20.04 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U build twine 27 | 28 | - name: Build package 29 | run: | 30 | python -m build 31 | twine check dist/* 32 | 33 | - name: Upload packages to Jazzband 34 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | user: jazzband 38 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 39 | repository_url: https://jazzband.co/projects/django-push-notifications/upload 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Get pip cache dir 23 | id: pip-cache 24 | run: | 25 | echo "::set-output name=dir::$(pip cache dir)" 26 | 27 | - name: Cache 28 | uses: actions/cache@v4 29 | with: 30 | path: ${{ steps.pip-cache.outputs.dir }} 31 | key: 32 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} 33 | restore-keys: | 34 | ${{ matrix.python-version }}-v1- 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install --upgrade tox tox-gh-actions 40 | python -m pip install setuptools-scm==6.4.2 41 | 42 | - name: Tox tests 43 | run: | 44 | tox -v 45 | 46 | - name: Upload coverage 47 | uses: codecov/codecov-action@v5 48 | with: 49 | name: Python ${{ matrix.python-version }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python compiled 2 | __pycache__ 3 | *.pyc 4 | 5 | # distutils 6 | MANIFEST 7 | build 8 | 9 | # IDE 10 | .idea 11 | *.iml 12 | 13 | # virtualenv 14 | .env 15 | 16 | # tox 17 | .tox 18 | *.egg-info/ 19 | .eggs 20 | 21 | # coverage 22 | .coverage 23 | coverage.xml 24 | 25 | # vscode files 26 | .vscode/* 27 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/asottile/pyupgrade 12 | rev: v3.19.1 13 | hooks: 14 | - id: pyupgrade 15 | -------------------------------------------------------------------------------- /ACKNOWLEDGEMENTS.md: -------------------------------------------------------------------------------- 1 | This library was created by Jerome Leclanche , for use on the 2 | Anthill application (https://www.anthill.com). 3 | 4 | Special thanks to the following core and frequent contributors: 5 | 6 | Adam "Cezar" Jenkins 7 | Arthur Silva 8 | Camille Fabreguettes 9 | Jamaal Scarlett 10 | Matthew Hershberger 11 | Pablo Martín 12 | 13 | 14 | The full contributor list is available at the following URL: 15 | 16 | https://github.com/jazzband/django-push-notifications/graphs/contributors 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 (unreleased) 2 | * BACKWARDS-INCOMPATIBLE: Drop support for Django Rest Framework < 3.7 3 | * BACKWARDS-INCOMPATIBLE: NotificationError is now moved from `__init__.py` to `exceptions.py` 4 | * Import with `from push_notifications.exceptions import NotificationError` 5 | * PYTHON: Add support for Python 3.7 6 | * APNS: Drop apns_errors, use exception class name instead 7 | * FCM: Add FCM channels support for custom notification sound on Android Oreo 8 | * BUGFIX: Fix error when send a message and the device is not active 9 | * BUGFIX: Fix error when APN bulk messages sent with localized keys and badge function 10 | * BUGFIX: Fix `Push failed: 403 fobidden` error when sending message to Chrome WebPushDevice 11 | 12 | 13 | ## 1.6.1 (2019-08-16) 14 | * Pin dependency to apns to <0.6.0 to fix a Python version 15 | incompatibility. 16 | * Add configuration for semi-automatic releases via Jazzband. 17 | 18 | ## 1.6.0 (2018-01-31) 19 | * BACKWARDS-INCOMPATIBLE: Drop support for Django < 1.11 20 | * DJANGO: Support Django 2.0 21 | * NEW FEATURE: Add support for WebPush 22 | 23 | 24 | ## 1.5.0 (2017-04-16) 25 | * BACKWARDS-INCOMPATIBLE: Remove `push_notifications.api.tastypie` module. Only DRF is supported now. 26 | * BACKWARDS-INCOMPATIBLE: Drop support for Django < 1.10 27 | * BACKWARDS-INCOMPATIBLE: Drop support for Django Rest Framework < 3.5 28 | * DJANGO: Support Django 1.10, 1.11 29 | * APNS: APNS is now supported using PyAPNS2 instead of an internal implementation. 30 | * APNS: Stricter certificate validity checks 31 | * APNS: Allow overriding the certfile from send_message() 32 | * APNS: Add human-readable error messages 33 | * APNS: Support thread-id in payload 34 | * FCM: Add support for FCM (Firebase Cloud Messaging) 35 | * FCM: Introduce `use_fcm_notification` option to enforce legacy GCM payload 36 | * GCM: Add GCM_ERROR_TIMEOUT setting 37 | * GCM: Fix support for sending GCM messages to topic subscribers 38 | * WNS: Add support for WNS (Windows Notification Service) 39 | * MISC: Make get_expired_tokens available in push_notifications.utils 40 | 41 | 42 | ## 1.4.1 (2016-01-11) 43 | * APNS: Increased max device token size to 100 bytes (WWDC 2015, iOS 9) 44 | * BUGFIX: Fix an index error in the admin 45 | 46 | 47 | ## 1.4.0 (2015-12-13) 48 | * BACKWARDS-INCOMPATIBLE: Drop support for Python<3.4 49 | * DJANGO: Support Django 1.9 50 | * GCM: Handle canonical IDs 51 | * GCM: Allow full range of GCMDevice.device_id values 52 | * GCM: Do not allow duplicate registration_ids 53 | * DRF: Work around empty boolean defaults issue (django-rest-framework#1101) 54 | * BUGFIX: Do not throw GCMError in bulk messages from the admin 55 | * BUGFIX: Avoid generating an extra migration on Python 3 56 | * BUGFIX: Only send in bulk to active devices 57 | * BUGFIX: Display models correctly in the admin on both Python 2 and 3 58 | 59 | 60 | ## 1.3.0 (2015-06-30) 61 | * BACKWARDS-INCOMPATIBLE: Drop support for Python<2.7 62 | * BACKWARDS-INCOMPATIBLE: Drop support for Django<1.8 63 | * NEW FEATURE: Added a Django Rest Framework API. Requires DRF>=3.0. 64 | * APNS: Add support for setting the ca_certs file with new APNS_CA_CERTIFICATES setting 65 | * GCM: Deactivate GCMDevices when their notifications cause NotRegistered or InvalidRegistration 66 | * GCM: Indiscriminately handle all keyword arguments in gcm_send_message and gcm_send_bulk_message 67 | * GCM: Never fall back to json in gcm_send_message 68 | * BUGFIX: Fixed migration issues from 1.2.0 upgrade. 69 | * BUGFIX: Better detection of SQLite/GIS MySQL in various checks 70 | * BUGFIX: Assorted Python 3 bugfixes 71 | * BUGFIX: Fix display of device_id in admin 72 | 73 | 74 | ## 1.2.1 (2015-04-11) 75 | * APNS, GCM: Add a db_index to the device_id field 76 | * APNS: Use the native UUIDField on Django 1.8 77 | * APNS: Fix timeout handling on Python 3 78 | * APNS: Restore error checking on apns_send_bulk_message 79 | * GCM: Expose the time_to_live argument in gcm_send_bulk_message 80 | * GCM: Fix return value when gcm bulk is split in batches 81 | * GCM: Improved error checking reliability 82 | * GCM: Properly pass kwargs in GCMDeviceQuerySet.send_message() 83 | * BUGFIX: Fix HexIntegerField for Django 1.3 84 | 85 | 86 | ## 1.2.0 (2014-10-07) 87 | * BACKWARDS-INCOMPATIBLE: Added support for Django 1.7 migrations. South users will have to upgrade to South 1.0 or Django 1.7. 88 | * APNS: APNS MAX_NOTIFICATION_SIZE is now a setting and its default has been increased to 2048 89 | * APNS: Always connect with TLSv1 instead of SSLv3 90 | * APNS: Implemented support for APNS Feedback Service 91 | * APNS: Support for optional "category" dict 92 | * GCM: Improved error handling in bulk mode 93 | * GCM: Added support for time_to_live parameter 94 | * BUGFIX: Fixed various issues relating HexIntegerField 95 | * BUGFIX: Fixed issues in the admin with custom user models 96 | 97 | 98 | ## 1.1.0 (2014-06-29) 99 | * BACKWARDS-INCOMPATIBLE: The arguments for device.send_message() have changed. See README.rst for details. 100 | * Added a date_created field to GCMDevice and APNSDevice. This field keeps track of when the Device was created. 101 | This requires a `manage.py migrate`. 102 | * Updated APNS protocol support 103 | * Allow sending empty sounds on APNS 104 | * Several APNS bugfixes 105 | * Fixed BigIntegerField support on PostGIS 106 | * Assorted migrations bugfixes 107 | * Added a test suite 108 | 109 | 110 | ## 1.0.1 (2013-01-16) 111 | * Migrations have been reset. If you were using migrations pre-1.0 you should upgrade to 1.0 instead and only 112 | upgrade to 1.0.1 when you are ready to reset your migrations. 113 | 114 | 115 | ## 1.0 (2013-01-15) 116 | * Full Python 3 support 117 | * GCM device_id is now a custom field based on BigIntegerField and always unsigned (it should be input as hex) 118 | * Django versions older than 1.5 now require 'six' to be installed 119 | * Drop uniqueness on gcm registration_id due to compatibility issues with MySQL 120 | * Fix some issues with migrations 121 | * Add some basic tests 122 | * Integrate with travis-ci 123 | * Add an AUTHORS file 124 | 125 | 126 | ## 0.9 (2013-12-17) 127 | * Enable installation with pip 128 | * Add wheel support 129 | * Add full documentation 130 | * Various bug fixes 131 | 132 | 133 | ## 0.8 (2013-03-15) 134 | * Initial release 135 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Jazzband 2 | 3 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 4 | 5 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 6 | 7 | 8 | ## Coding style 9 | This project follows the [HearthSim Styleguide](https://hearthsim.info/styleguide/). 10 | 11 | In short: 12 | 13 | 1. Always use tabs. [Here](https://leclan.ch/tabs) is a short explanation why tabs are preferred. 14 | 2. Always use double quotes for strings, unless single quotes avoid unnecessary escapes. 15 | 3. When in doubt, [PEP8](https://www.python.org/dev/peps/pep-0008/). Follow its naming conventions. 16 | 4. Know when to make exceptions. 17 | 18 | Also see: [How to name things in programming](http://www.slideshare.net/pirhilton/how-to-name-things-the-hardest-problem-in-programming) 19 | 20 | Flake8 tests are available with `tox -e flake8`. Run them before you commit! 21 | 22 | 23 | ## Commits and Pull Requests 24 | Keep the commit log as healthy as the code. It is one of the first places new contributors will look at the project. 25 | 26 | 1. No more than one change per commit. There should be no changes in a commit which are unrelated to its message. 27 | 2. Every commit should pass all tests on its own. 28 | 3. Follow [these conventions](http://chris.beams.io/posts/git-commit/) when writing the commit message 29 | 30 | When filing a Pull Request, make sure it is rebased on top of most recent master. 31 | If you need to modify it or amend it in some way, you should always appropriately 32 | [fixup](https://help.github.com/articles/about-git-rebase/) the issues in git and force-push your changes to your fork. 33 | 34 | Also see: [Github Help: Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Jerome Leclanche 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /docs/APNS.rst: -------------------------------------------------------------------------------- 1 | Generation of an APNS PEM file 2 | ------------------------------ 3 | 4 | The ``APNS_CERTIFICATE`` setting must reference the location of a PEM file. This file must 5 | contain a certificate and private key pair allowing a secure connection to Apple's push gateway. 6 | 7 | These instructions assume the use of Mac OS X. 8 | 9 | There are two main steps involved; generating the certificate, then conversion of this certificate into `PEM` format for use with this library. 10 | 11 | **Generating the push certificate** 12 | 13 | Using `Apple's Developer site `_ you need to generate a push notification certificate for either development or production. There are countless instructions online and Apple change their flow for this regularly, so it is not documented here. The end result should be an exported certificate and private key with the `p12` extension. 14 | 15 | When initiating the certificate generation flow in Apple's Dev site, do this from within the specific app's configuration: 16 | 17 | Identifiers -> App IDs -> [Your App] -> Edit -> Push Notifications Section (Create Certificate) . 18 | 19 | If you initiate this flow from the top level `Certificates` section, the resulting export may contain both sandbox and production certificates and keys, which confuses matters a lot. 20 | 21 | **Converting the certificate to `PEM` format** 22 | 23 | The flow is similar for development and production environments. These steps are adapted from `a Stack Overflow post `_. 24 | 25 | **Step 1:** Create Certificate .pem from Certificate .p12 26 | 27 | .. code-block:: bash 28 | 29 | $ openssl pkcs12 -clcerts -nokeys -out aps-cert.pem -in Certificates.p12 30 | 31 | **Step 2** Create Key .pem from Key .p12 32 | 33 | .. code-block:: bash 34 | 35 | $ openssl pkcs12 -nocerts -out aps-key.pem -in Certificates.p12 36 | 37 | **Step 3** Remove pass phrase on the key 38 | 39 | .. code-block:: bash 40 | 41 | $ openssl rsa -in aps-key.pem -out aps-key-noenc.pem 42 | 43 | **Step 4** Combine the two into one file 44 | 45 | .. code-block:: bash 46 | 47 | $ cat aps-cert.pem aps-key-noenc.pem > aps.pem 48 | 49 | **Step 5** Check certificate validity and connectivity to APNS 50 | 51 | .. code-block:: bash 52 | 53 | $ openssl s_client -connect gateway.push.apple.com:2195 -cert aps-cert.pem -key aps-key-noenc.pem 54 | 55 | If the certificate and key are valid, the connection will open and remain open. If it is not 56 | the connection will be closed and an error potentially displayed. 57 | 58 | To test if the certificate works in sandbox mode, simply replace the `gateway` with `gateway.sandbox.push.apple.com:2195`. 59 | -------------------------------------------------------------------------------- /docs/FCM.rst: -------------------------------------------------------------------------------- 1 | Generate service account private key file 2 | ------------------------------ 3 | 4 | Migrating to FCM v1 API 5 | ------------------------------ 6 | 7 | - GCM and legacy FCM API support have been removed. (GCM is off since 2019, FCM legacy will be turned off in june 2024) 8 | - Firebase-Admin SDK has been added 9 | 10 | 11 | Authentication does not work with an access token anymore. 12 | Follow the `official docs `_ to generate a service account private key file. 13 | 14 | Then, either define an environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` with the path to the service account private key file, or pass the path to the file explicitly when initializing the SDK. 15 | 16 | Initialize the firebase admin in your ``settings.py`` file. 17 | 18 | .. code-block:: python 19 | 20 | # Import the firebase service 21 | import firebase_admin 22 | 23 | # Initialize the default app 24 | default_app = firebase_admin.initialize_app() 25 | 26 | 27 | This will do the trick. 28 | 29 | 30 | Multiple Application Support 31 | ------------------------------ 32 | 33 | Removed settings: 34 | 35 | - ``API_KEY`` 36 | - ``POST_URL`` 37 | - ``ERROR_TIMEOUT`` 38 | 39 | Added setting: 40 | 41 | - ``FIREBASE_APP``: initialise your firebase app and set it here. 42 | 43 | 44 | .. code-block:: python 45 | 46 | # Before 47 | PUSH_NOTIFICATIONS_SETTINGS = { 48 | # Load and process all PUSH_NOTIFICATIONS_SETTINGS using the AppConfig manager. 49 | "CONFIG": "push_notifications.conf.AppConfig", 50 | 51 | # collection of all defined applications 52 | "APPLICATIONS": { 53 | "my_fcm_app": { 54 | # PLATFORM (required) determines what additional settings are required. 55 | "PLATFORM": "FCM", 56 | 57 | # required FCM setting 58 | "API_KEY": "[your api key]", 59 | }, 60 | "my_ios_app": { 61 | # PLATFORM (required) determines what additional settings are required. 62 | "PLATFORM": "APNS", 63 | 64 | # required APNS setting 65 | "CERTIFICATE": "/path/to/your/certificate.pem", 66 | }, 67 | "my_wns_app": { 68 | # PLATFORM (required) determines what additional settings are required. 69 | "PLATFORM": "WNS", 70 | 71 | # required WNS settings 72 | "PACKAGE_SECURITY_ID": "[your package security id, e.g: 'ms-app://e-3-4-6234...']", 73 | "SECRET_KEY": "[your app secret key, e.g.: 'KDiejnLKDUWodsjmewuSZkk']", 74 | }, 75 | } 76 | } 77 | 78 | # After 79 | 80 | firebase_app = firebase_admin.initialize_app() 81 | 82 | PUSH_NOTIFICATIONS_SETTINGS = { 83 | # Load and process all PUSH_NOTIFICATIONS_SETTINGS using the AppConfig manager. 84 | "CONFIG": "push_notifications.conf.AppConfig", 85 | 86 | # collection of all defined applications 87 | "APPLICATIONS": { 88 | "my_fcm_app": { 89 | # PLATFORM (required) determines what additional settings are required. 90 | "PLATFORM": "FCM", 91 | 92 | # FCM settings 93 | "FIREBASE_APP": firebase_app, 94 | }, 95 | "my_ios_app": { 96 | # PLATFORM (required) determines what additional settings are required. 97 | "PLATFORM": "APNS", 98 | 99 | # required APNS setting 100 | "CERTIFICATE": "/path/to/your/certificate.pem", 101 | }, 102 | "my_wns_app": { 103 | # PLATFORM (required) determines what additional settings are required. 104 | "PLATFORM": "WNS", 105 | 106 | # required WNS settings 107 | "PACKAGE_SECURITY_ID": "[your package security id, e.g: 'ms-app://e-3-4-6234...']", 108 | "SECRET_KEY": "[your app secret key, e.g.: 'KDiejnLKDUWodsjmewuSZkk']", 109 | }, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /docs/WebPush.rst: -------------------------------------------------------------------------------- 1 | At a high-level, the key steps for implementing web push notifications after installing django-push-notifications[WP] are: 2 | - Configure the VAPID keys, a private and public key for signing your push requests. 3 | - Add client side logic to ask the user for permission to send push notifications and then sending returned client identifier information to a django view to create a WebPushDevice. 4 | - Use a service worker to receive messages that have been pushed to the device and displaying them as notifications. 5 | 6 | These are in addition to the instalation steps for django-push-notifications[WP] 7 | 8 | Configure the VAPID keys 9 | ------------------------------ 10 | - Install: 11 | 12 | .. code-block:: python 13 | 14 | pip install py-vapid (Only for generating key) 15 | 16 | - Generate public and private keys: 17 | 18 | .. code-block:: bash 19 | 20 | vapid --gen 21 | 22 | Generating private_key.pem 23 | Generating public_key.pem 24 | 25 | 26 | The private key generated is the file to use with the setting ``WP_PRIVATE_KEY`` 27 | The public key will be used in your client side javascript, but first it must be formated as an Application Server Key 28 | 29 | - Generate client public key (applicationServerKey) 30 | 31 | .. code-block:: bash 32 | 33 | vapid --applicationServerKey 34 | 35 | Application Server Key = 36 | 37 | 38 | 39 | Client Side logic to ask user for permission and subscribe to WebPush 40 | ------------------------------ 41 | The example subscribeUser function is best called in response to a user action, such as a button click. Some browsers will deny the request otherwise. 42 | 43 | .. code-block:: javascript 44 | 45 | // Utils functions: 46 | 47 | function urlBase64ToUint8Array (base64String) { 48 | var padding = '='.repeat((4 - base64String.length % 4) % 4) 49 | var base64 = (base64String + padding) 50 | .replace(/\-/g, '+') 51 | .replace(/_/g, '/') 52 | 53 | var rawData = window.atob(base64) 54 | var outputArray = new Uint8Array(rawData.length) 55 | 56 | for (var i = 0; i < rawData.length; ++i) { 57 | outputArray[i] = rawData.charCodeAt(i) 58 | } 59 | return outputArray; 60 | } 61 | 62 | var applicationServerKey = ''; 63 | 64 | function subscribeUser() { 65 | if ('Notification' in window && 'serviceWorker' in navigator) { 66 | navigator.serviceWorker.ready.then(function (reg) { 67 | reg.pushManager 68 | .subscribe({ 69 | userVisibleOnly: true, 70 | applicationServerKey: urlBase64ToUint8Array( 71 | applicationServerKey 72 | ), 73 | }) 74 | .then(function (sub) { 75 | var registration_id = sub.endpoint; 76 | var data = { 77 | p256dh: btoa( 78 | String.fromCharCode.apply( 79 | null, 80 | new Uint8Array(sub.getKey('p256dh')) 81 | ) 82 | ), 83 | auth: btoa( 84 | String.fromCharCode.apply( 85 | null, 86 | new Uint8Array(sub.getKey('auth')) 87 | ) 88 | ), 89 | registration_id: registration_id, 90 | } 91 | requestPOSTToServer(data) 92 | }) 93 | .catch(function (e) { 94 | if (Notification.permission === 'denied') { 95 | console.warn('Permission for notifications was denied') 96 | } else { 97 | console.error('Unable to subscribe to push', e) 98 | } 99 | }) 100 | }) 101 | } 102 | } 103 | 104 | // Send the subscription data to your server 105 | function requestPOSTToServer (data) { 106 | const headers = new Headers(); 107 | headers.set('Content-Type', 'application/json'); 108 | const requestOptions = { 109 | method: 'POST', 110 | headers, 111 | body: JSON.stringify(data), 112 | }; 113 | 114 | return ( 115 | fetch( 116 | '', 117 | requestOptions 118 | ) 119 | ).then((response) => response.json()) 120 | } 121 | 122 | Server Side logic to create webpush 123 | ------------------------------ 124 | It is up to you how to add a view in your django application that can handle a POST of p256dh, auth, registration_id and create a WebPushDevice with those values assoicated with the appropriate user. 125 | For example you could use rest_framework 126 | 127 | .. code-block:: python 128 | 129 | from rest_framework.routers import SimpleRouter 130 | from push_notifications.api.rest_framework import WebPushDeviceViewSet 131 | .... 132 | api_router = SimpleRouter() 133 | api_router.register(r'push/web', WebPushDeviceViewSet, basename='web_push') 134 | ... 135 | urlpatterns += [ 136 | # Api 137 | re_path('api/v1/', include(api_router.urls)), 138 | ... 139 | ] 140 | 141 | Or a generic function view (add your own boilerplate for errors and protections) 142 | 143 | .. code-block:: python 144 | 145 | import json 146 | from push_notifications.models import WebPushDevice 147 | def register_webpush(request): 148 | data = json.loads(request.body) 149 | WebPushDevice.objects.create( 150 | user=request.user, 151 | **data 152 | ) 153 | 154 | 155 | Service Worker to show messages 156 | ------------------------------ 157 | You will need a service worker registered with your web app that can handle the notfications, for example 158 | 159 | .. code-block:: javascript 160 | 161 | // Example navigatorPush.service.js file 162 | 163 | var getTitle = function (title) { 164 | if (title === "") { 165 | title = "TITLE DEFAULT"; 166 | } 167 | return title; 168 | }; 169 | var getNotificationOptions = function (message, message_tag) { 170 | var options = { 171 | body: message, 172 | icon: '/img/icon_120.png', 173 | tag: message_tag, 174 | vibrate: [200, 100, 200, 100, 200, 100, 200] 175 | }; 176 | return options; 177 | }; 178 | 179 | self.addEventListener('install', function (event) { 180 | self.skipWaiting(); 181 | }); 182 | 183 | self.addEventListener('push', function(event) { 184 | try { 185 | // Push is a JSON 186 | var response_json = event.data.json(); 187 | var title = response_json.title; 188 | var message = response_json.message; 189 | var message_tag = response_json.tag; 190 | } catch (err) { 191 | // Push is a simple text 192 | var title = ""; 193 | var message = event.data.text(); 194 | var message_tag = ""; 195 | } 196 | self.registration.showNotification(getTitle(title), getNotificationOptions(message, message_tag)); 197 | // Optional: Comunicating with our js application. Send a signal 198 | self.clients.matchAll({includeUncontrolled: true, type: 'window'}).then(function (clients) { 199 | clients.forEach(function (client) { 200 | client.postMessage({ 201 | "data": message_tag, 202 | "data_title": title, 203 | "data_body": message}); 204 | }); 205 | }); 206 | }); 207 | 208 | // Optional: Added to that the browser opens when you click on the notification push web. 209 | self.addEventListener('notificationclick', function(event) { 210 | // Android doesn't close the notification when you click it 211 | // See http://crbug.com/463146 212 | event.notification.close(); 213 | // Check if there's already a tab open with this URL. 214 | // If yes: focus on the tab. 215 | // If no: open a tab with the URL. 216 | event.waitUntil(clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(windowClients) { 217 | for (var i = 0; i < windowClients.length; i++) { 218 | var client = windowClients[i]; 219 | if ('focus' in client) { 220 | return client.focus(); 221 | } 222 | } 223 | }) 224 | ); 225 | }); 226 | -------------------------------------------------------------------------------- /push_notifications/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | # Python 3.8+ 3 | import importlib.metadata as importlib_metadata 4 | except ImportError: 5 | # UNSIGNED_64BIT_INT_MAX_VALUE: 109 | raise ValidationError("Device ID is out of range") 110 | return value 111 | 112 | 113 | class WNSDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer): 114 | class Meta(DeviceSerializerMixin.Meta): 115 | model = WNSDevice 116 | 117 | 118 | class WebPushDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer): 119 | class Meta(DeviceSerializerMixin.Meta): 120 | model = WebPushDevice 121 | fields = ( 122 | "id", "name", "registration_id", "active", "date_created", 123 | "p256dh", "auth", "browser", "application_id", 124 | ) 125 | 126 | 127 | # Permissions 128 | class IsOwner(permissions.BasePermission): 129 | def has_object_permission(self, request, view, obj): 130 | # must be the owner to view the object 131 | return obj.user == request.user 132 | 133 | 134 | # Mixins 135 | class DeviceViewSetMixin: 136 | lookup_field = "registration_id" 137 | 138 | def create(self, request, *args, **kwargs): 139 | serializer = None 140 | is_update = False 141 | if SETTINGS.get("UPDATE_ON_DUPLICATE_REG_ID") and self.lookup_field in request.data: 142 | instance = self.queryset.model.objects.filter( 143 | registration_id=request.data[self.lookup_field] 144 | ).first() 145 | if instance: 146 | serializer = self.get_serializer(instance, data=request.data) 147 | is_update = True 148 | if not serializer: 149 | serializer = self.get_serializer(data=request.data) 150 | 151 | serializer.is_valid(raise_exception=True) 152 | if is_update: 153 | self.perform_update(serializer) 154 | return Response(serializer.data) 155 | else: 156 | self.perform_create(serializer) 157 | headers = self.get_success_headers(serializer.data) 158 | return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 159 | 160 | def perform_create(self, serializer): 161 | if self.request.user.is_authenticated: 162 | serializer.save(user=self.request.user) 163 | return super().perform_create(serializer) 164 | 165 | def perform_update(self, serializer): 166 | if self.request.user.is_authenticated: 167 | serializer.save(user=self.request.user) 168 | return super().perform_update(serializer) 169 | 170 | 171 | class AuthorizedMixin: 172 | permission_classes = (permissions.IsAuthenticated, IsOwner) 173 | 174 | def get_queryset(self): 175 | # filter all devices to only those belonging to the current user 176 | return self.queryset.filter(user=self.request.user) 177 | 178 | 179 | # ViewSets 180 | class APNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 181 | queryset = APNSDevice.objects.all() 182 | serializer_class = APNSDeviceSerializer 183 | 184 | 185 | class APNSDeviceAuthorizedViewSet(AuthorizedMixin, APNSDeviceViewSet): 186 | pass 187 | 188 | 189 | class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 190 | queryset = GCMDevice.objects.all() 191 | serializer_class = GCMDeviceSerializer 192 | 193 | 194 | class GCMDeviceAuthorizedViewSet(AuthorizedMixin, GCMDeviceViewSet): 195 | pass 196 | 197 | 198 | class WNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 199 | queryset = WNSDevice.objects.all() 200 | serializer_class = WNSDeviceSerializer 201 | 202 | 203 | class WNSDeviceAuthorizedViewSet(AuthorizedMixin, WNSDeviceViewSet): 204 | pass 205 | 206 | 207 | class WebPushDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 208 | queryset = WebPushDevice.objects.all() 209 | serializer_class = WebPushDeviceSerializer 210 | 211 | 212 | class WebPushDeviceAuthorizedViewSet(AuthorizedMixin, WebPushDeviceViewSet): 213 | pass 214 | -------------------------------------------------------------------------------- /push_notifications/apns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apple Push Notification Service 3 | Documentation is available on the iOS Developer Library: 4 | https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html 5 | """ 6 | 7 | import time 8 | 9 | from apns2 import client as apns2_client 10 | from apns2 import credentials as apns2_credentials 11 | from apns2 import errors as apns2_errors 12 | from apns2 import payload as apns2_payload 13 | 14 | from . import models 15 | from .conf import get_manager 16 | from .exceptions import APNSError, APNSUnsupportedPriority, APNSServerError 17 | 18 | 19 | def _apns_create_socket(creds=None, application_id=None): 20 | if creds is None: 21 | if not get_manager().has_auth_token_creds(application_id): 22 | cert = get_manager().get_apns_certificate(application_id) 23 | creds = apns2_credentials.CertificateCredentials(cert) 24 | else: 25 | keyPath, keyId, teamId = get_manager().get_apns_auth_creds(application_id) 26 | # No use getting a lifetime because this credential is 27 | # ephemeral, but if you're looking at this to see how to 28 | # create a credential, you could also pass the lifetime and 29 | # algorithm. Neither of those settings are exposed in the 30 | # settings API at the moment. 31 | creds = creds or apns2_credentials.TokenCredentials(keyPath, keyId, teamId) 32 | client = apns2_client.APNsClient( 33 | creds, 34 | use_sandbox=get_manager().get_apns_use_sandbox(application_id), 35 | use_alternative_port=get_manager().get_apns_use_alternative_port(application_id) 36 | ) 37 | client.connect() 38 | return client 39 | 40 | 41 | def _apns_prepare( 42 | token, alert, application_id=None, badge=None, sound=None, category=None, 43 | content_available=False, action_loc_key=None, loc_key=None, loc_args=[], 44 | extra={}, mutable_content=False, thread_id=None, url_args=None): 45 | if action_loc_key or loc_key or loc_args: 46 | apns2_alert = apns2_payload.PayloadAlert( 47 | body=alert if alert else {}, body_localized_key=loc_key, 48 | body_localized_args=loc_args, action_localized_key=action_loc_key) 49 | else: 50 | apns2_alert = alert 51 | 52 | if callable(badge): 53 | badge = badge(token) 54 | 55 | return apns2_payload.Payload( 56 | alert=apns2_alert, badge=badge, sound=sound, category=category, 57 | url_args=url_args, custom=extra, thread_id=thread_id, 58 | content_available=content_available, mutable_content=mutable_content) 59 | 60 | 61 | def _apns_send( 62 | registration_id, alert, batch=False, application_id=None, creds=None, **kwargs 63 | ): 64 | client = _apns_create_socket(creds=creds, application_id=application_id) 65 | 66 | notification_kwargs = {} 67 | 68 | # if expiration isn"t specified use 1 month from now 69 | notification_kwargs["expiration"] = kwargs.pop("expiration", None) 70 | if not notification_kwargs["expiration"]: 71 | notification_kwargs["expiration"] = int(time.time()) + 2592000 72 | 73 | priority = kwargs.pop("priority", None) 74 | if priority: 75 | try: 76 | notification_kwargs["priority"] = apns2_client.NotificationPriority(str(priority)) 77 | except ValueError: 78 | raise APNSUnsupportedPriority("Unsupported priority %d" % (priority)) 79 | 80 | notification_kwargs["collapse_id"] = kwargs.pop("collapse_id", None) 81 | 82 | if batch: 83 | data = [apns2_client.Notification( 84 | token=rid, payload=_apns_prepare(rid, alert, **kwargs)) for rid in registration_id] 85 | # returns a dictionary mapping each token to its result. That 86 | # result is either "Success" or the reason for the failure. 87 | return client.send_notification_batch( 88 | data, get_manager().get_apns_topic(application_id=application_id), 89 | **notification_kwargs 90 | ) 91 | 92 | data = _apns_prepare(registration_id, alert, **kwargs) 93 | client.send_notification( 94 | registration_id, data, 95 | get_manager().get_apns_topic(application_id=application_id), 96 | **notification_kwargs 97 | ) 98 | 99 | 100 | def apns_send_message(registration_id, alert, application_id=None, creds=None, **kwargs): 101 | """ 102 | Sends an APNS notification to a single registration_id. 103 | This will send the notification as form data. 104 | If sending multiple notifications, it is more efficient to use 105 | apns_send_bulk_message() 106 | 107 | Note that if set alert should always be a string. If it is not set, 108 | it won"t be included in the notification. You will need to pass None 109 | to this for silent notifications. 110 | """ 111 | 112 | try: 113 | _apns_send( 114 | registration_id, alert, application_id=application_id, 115 | creds=creds, **kwargs 116 | ) 117 | except apns2_errors.APNsException as apns2_exception: 118 | if isinstance(apns2_exception, apns2_errors.Unregistered): 119 | models.APNSDevice.objects.filter(registration_id=registration_id).update(active=False) 120 | 121 | raise APNSServerError(status=apns2_exception.__class__.__name__) 122 | 123 | 124 | def apns_send_bulk_message( 125 | registration_ids, alert, application_id=None, creds=None, **kwargs 126 | ): 127 | """ 128 | Sends an APNS notification to one or more registration_ids. 129 | The registration_ids argument needs to be a list. 130 | 131 | Note that if set alert should always be a string. If it is not set, 132 | it won"t be included in the notification. You will need to pass None 133 | to this for silent notifications. 134 | """ 135 | 136 | results = _apns_send( 137 | registration_ids, alert, batch=True, application_id=application_id, 138 | creds=creds, **kwargs 139 | ) 140 | inactive_tokens = [token for token, result in results.items() if result == "Unregistered"] 141 | models.APNSDevice.objects.filter(registration_id__in=inactive_tokens).update(active=False) 142 | return results 143 | -------------------------------------------------------------------------------- /push_notifications/apns_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | from dataclasses import asdict, dataclass 5 | from typing import Awaitable, Callable, Dict, Optional, Union 6 | 7 | from aioapns import APNs, ConnectionError, NotificationRequest 8 | from aioapns.common import NotificationResult 9 | 10 | from . import models 11 | from .conf import get_manager 12 | from .exceptions import APNSServerError, APNSError 13 | 14 | ErrFunc = Optional[Callable[[NotificationRequest, NotificationResult], Awaitable[None]]] 15 | """function to proces errors from aioapns send_message""" 16 | 17 | 18 | class NotSet: 19 | def __init__(self): 20 | raise RuntimeError("NotSet cannot be instantiated") 21 | 22 | 23 | class Credentials: 24 | pass 25 | 26 | 27 | @dataclass 28 | class TokenCredentials(Credentials): 29 | key: str 30 | key_id: str 31 | team_id: str 32 | 33 | 34 | @dataclass 35 | class CertificateCredentials(Credentials): 36 | client_cert: str 37 | 38 | 39 | @dataclass 40 | class Alert: 41 | """ 42 | The information for displaying an alert. A dictionary is recommended. If you specify a string, the alert displays your string as the body text. 43 | 44 | https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification 45 | """ 46 | 47 | title: str = NotSet 48 | """ 49 | The title of the notification. Apple Watch displays this string in the short look notification interface. Specify a string that’s quickly understood by the user. 50 | """ 51 | 52 | subtitle: str = NotSet 53 | """ 54 | Additional information that explains the purpose of the notification. 55 | """ 56 | 57 | body: str = NotSet 58 | """ 59 | The content of the alert message. 60 | """ 61 | 62 | launch_image: str = NotSet 63 | """ 64 | The name of the launch image file to display. If the user chooses to launch your app, the contents of the specified image or storyboard file are displayed instead of your app’s normal launch image. 65 | """ 66 | 67 | title_loc_key: str = NotSet 68 | """ 69 | The key for a localized title string. Specify this key instead of the title key to retrieve the title from your app’s Localizable.strings files. The value must contain the name of a key in your strings file 70 | """ 71 | 72 | title_loc_args: list[str] = NotSet 73 | """ 74 | An array of strings containing replacement values for variables in your title string. Each %@ character in the string specified by the title-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. 75 | """ 76 | 77 | subtitle_loc_key: str = NotSet 78 | """ 79 | The key for a localized subtitle string. Use this key, instead of the subtitle key, to retrieve the subtitle from your app’s Localizable.strings file. The value must contain the name of a key in your strings file. 80 | """ 81 | 82 | subtitle_loc_args: list[str] = NotSet 83 | """ 84 | An array of strings containing replacement values for variables in your title string. Each %@ character in the string specified by subtitle-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. 85 | """ 86 | 87 | loc_key: str = NotSet 88 | """ 89 | The key for a localized message string. Use this key, instead of the body key, to retrieve the message text from your app’s Localizable.strings file. The value must contain the name of a key in your strings file. 90 | """ 91 | 92 | loc_args: list[str] = NotSet 93 | """ 94 | An array of strings containing replacement values for variables in your message text. Each %@ character in the string specified by loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. 95 | """ 96 | 97 | sound: Union[str, any] = NotSet 98 | """ 99 | string 100 | The name of a sound file in your app’s main bundle or in the Library/Sounds folder of your app’s container directory. Specify the string “default” to play the system sound. Use this key for regular notifications. For critical alerts, use the sound dictionary instead. For information about how to prepare sounds, see UNNotificationSound. 101 | 102 | dictionary 103 | A dictionary that contains sound information for critical alerts. For regular notifications, use the sound string instead. 104 | """ 105 | 106 | def asDict(self) -> dict[str, any]: 107 | python_dict = asdict(self) 108 | return { 109 | key.replace("_", "-"): value 110 | for key, value in python_dict.items() 111 | if value is not NotSet 112 | } 113 | 114 | 115 | def _create_notification_request_from_args( 116 | registration_id: str, 117 | alert: Union[str, Alert], 118 | badge: int = None, 119 | sound: str = None, 120 | extra: dict = {}, 121 | expiration: int = None, 122 | thread_id: str = None, 123 | loc_key: str = None, 124 | priority: int = None, 125 | collapse_id: str = None, 126 | aps_kwargs: dict = {}, 127 | message_kwargs: dict = {}, 128 | notification_request_kwargs: dict = {}, 129 | ): 130 | if alert is None: 131 | alert = Alert(body="") 132 | 133 | if loc_key: 134 | if isinstance(alert, str): 135 | alert = Alert(body=alert) 136 | alert.loc_key = loc_key 137 | 138 | if isinstance(alert, Alert): 139 | alert = alert.asDict() 140 | 141 | notification_request_kwargs_out = notification_request_kwargs.copy() 142 | 143 | if expiration is not None: 144 | notification_request_kwargs_out["time_to_live"] = expiration - int(time.time()) 145 | if priority is not None: 146 | notification_request_kwargs_out["priority"] = priority 147 | 148 | if collapse_id is not None: 149 | notification_request_kwargs_out["collapse_key"] = collapse_id 150 | 151 | request = NotificationRequest( 152 | device_token=registration_id, 153 | message={ 154 | "aps": { 155 | "alert": alert, 156 | "badge": badge, 157 | "sound": sound, 158 | "thread-id": thread_id, 159 | **aps_kwargs, 160 | }, 161 | **extra, 162 | **message_kwargs, 163 | }, 164 | **notification_request_kwargs_out, 165 | ) 166 | 167 | return request 168 | 169 | 170 | def _create_client( 171 | creds: Credentials = None, 172 | application_id: str = None, 173 | topic=None, 174 | err_func: ErrFunc = None, 175 | ) -> APNs: 176 | use_sandbox = get_manager().get_apns_use_sandbox(application_id) 177 | if topic is None: 178 | topic = get_manager().get_apns_topic(application_id) 179 | if creds is None: 180 | creds = _get_credentials(application_id) 181 | 182 | client = APNs( 183 | **asdict(creds), 184 | topic=topic, # Bundle ID 185 | use_sandbox=use_sandbox, 186 | err_func=err_func, 187 | ) 188 | return client 189 | 190 | 191 | def _get_credentials(application_id): 192 | if not get_manager().has_auth_token_creds(application_id): 193 | # TLS certificate authentication 194 | cert = get_manager().get_apns_certificate(application_id) 195 | return CertificateCredentials( 196 | client_cert=cert, 197 | ) 198 | else: 199 | # Token authentication 200 | keyPath, keyId, teamId = get_manager().get_apns_auth_creds(application_id) 201 | # No use getting a lifetime because this credential is 202 | # ephemeral, but if you're looking at this to see how to 203 | # create a credential, you could also pass the lifetime and 204 | # algorithm. Neither of those settings are exposed in the 205 | # settings API at the moment. 206 | return TokenCredentials(key=keyPath, key_id=keyId, team_id=teamId) 207 | 208 | 209 | def apns_send_message( 210 | registration_id: str, 211 | alert: Union[str, Alert], 212 | application_id: str = None, 213 | creds: Credentials = None, 214 | topic: str = None, 215 | badge: int = None, 216 | sound: str = None, 217 | content_available: bool = None, 218 | extra: dict = {}, 219 | expiration: int = None, 220 | thread_id: str = None, 221 | loc_key: str = None, 222 | priority: int = None, 223 | collapse_id: str = None, 224 | mutable_content: bool = False, 225 | category: str = None, 226 | err_func: ErrFunc = None, 227 | ): 228 | """ 229 | Sends an APNS notification to a single registration_id. 230 | If sending multiple notifications, it is more efficient to use 231 | apns_send_bulk_message() 232 | 233 | Note that if set alert should always be a string. If it is not set, 234 | it won"t be included in the notification. You will need to pass None 235 | to this for silent notifications. 236 | 237 | 238 | :param registration_id: The registration_id of the device to send to 239 | :param alert: The alert message to send 240 | :param application_id: The application_id to use 241 | :param creds: The credentials to use 242 | :param mutable_content: If True, the "mutable-content" flag will be set to 1. 243 | This allows the app's Notification Service Extension to modify 244 | the notification before it is displayed. 245 | :param category: The category identifier for actionable notifications. 246 | This should match a category identifier defined in the app's 247 | Notification Content Extension or UNNotificationCategory configuration. 248 | It allows the app to display custom actions with the notification. 249 | :param content_available: If True the `content-available` flag will be set to 1, allowing the app to be woken up in the background 250 | """ 251 | results = apns_send_bulk_message( 252 | registration_ids=[registration_id], 253 | alert=alert, 254 | application_id=application_id, 255 | creds=creds, 256 | topic=topic, 257 | badge=badge, 258 | sound=sound, 259 | content_available=content_available, 260 | extra=extra, 261 | expiration=expiration, 262 | thread_id=thread_id, 263 | loc_key=loc_key, 264 | priority=priority, 265 | collapse_id=collapse_id, 266 | mutable_content=mutable_content, 267 | category=category, 268 | err_func=err_func, 269 | ) 270 | 271 | for result in results.values(): 272 | if result == "Success": 273 | return {"results": [result]} 274 | else: 275 | return {"results": [{"error": result}]} 276 | 277 | 278 | def apns_send_bulk_message( 279 | registration_ids: list[str], 280 | alert: Union[str, Alert], 281 | application_id: str = None, 282 | creds: Credentials = None, 283 | topic: str = None, 284 | badge: int = None, 285 | sound: str = None, 286 | content_available: bool = None, 287 | extra: dict = {}, 288 | expiration: int = None, 289 | thread_id: str = None, 290 | loc_key: str = None, 291 | priority: int = None, 292 | collapse_id: str = None, 293 | mutable_content: bool = False, 294 | category: str = None, 295 | err_func: ErrFunc = None, 296 | ): 297 | """ 298 | Sends an APNS notification to one or more registration_ids. 299 | The registration_ids argument needs to be a list. 300 | 301 | Note that if set alert should always be a string. If it is not set, 302 | it won"t be included in the notification. You will need to pass None 303 | to this for silent notifications. 304 | 305 | :param registration_ids: A list of the registration_ids to send to 306 | :param alert: The alert message to send 307 | :param application_id: The application_id to use 308 | :param creds: The credentials to use 309 | :param mutable_content: If True, the "mutable-content" flag will be set to 1. 310 | This allows the app's Notification Service Extension to modify 311 | the notification before it is displayed. 312 | :param category: The category identifier for actionable notifications. 313 | This should match a category identifier defined in the app's 314 | Notification Content Extension or UNNotificationCategory configuration. 315 | It allows the app to display custom actions with the notification. 316 | :param content_available: If True the `content-available` flag will be set to 1, allowing the app to be woken up in the background 317 | """ 318 | try: 319 | topic = get_manager().get_apns_topic(application_id) 320 | results: Dict[str, str] = {} 321 | inactive_tokens = [] 322 | 323 | responses = asyncio.run( 324 | _send_bulk_request( 325 | registration_ids=registration_ids, 326 | alert=alert, 327 | application_id=application_id, 328 | creds=creds, 329 | topic=topic, 330 | badge=badge, 331 | sound=sound, 332 | content_available=content_available, 333 | extra=extra, 334 | expiration=expiration, 335 | thread_id=thread_id, 336 | loc_key=loc_key, 337 | priority=priority, 338 | collapse_id=collapse_id, 339 | mutable_content=mutable_content, 340 | category=category, 341 | err_func=err_func, 342 | ) 343 | ) 344 | 345 | results = {} 346 | errors = [] 347 | for registration_id, result in responses: 348 | results[registration_id] = ( 349 | "Success" if result.is_successful else result.description 350 | ) 351 | if not result.is_successful: 352 | errors.append(result.description) 353 | if result.description in [ 354 | "Unregistered", 355 | "BadDeviceToken", 356 | "DeviceTokenNotForTopic", 357 | ]: 358 | inactive_tokens.append(registration_id) 359 | 360 | if len(inactive_tokens) > 0: 361 | models.APNSDevice.objects.filter( 362 | registration_id__in=inactive_tokens 363 | ).update(active=False) 364 | 365 | if len(errors) > 0: 366 | msg = "One or more errors failed with errors: {}".format(", ".join(errors)) 367 | raise APNSError(msg) 368 | 369 | return results 370 | 371 | except ConnectionError as e: 372 | raise APNSServerError(status=e.__class__.__name__) 373 | 374 | 375 | async def _send_bulk_request( 376 | registration_ids: list[str], 377 | alert: Union[str, Alert], 378 | application_id: str = None, 379 | creds: Credentials = None, 380 | topic: str = None, 381 | badge: int = None, 382 | sound: str = None, 383 | content_available: bool = None, 384 | extra: dict = {}, 385 | expiration: int = None, 386 | thread_id: str = None, 387 | loc_key: str = None, 388 | priority: int = None, 389 | collapse_id: str = None, 390 | mutable_content: bool = False, 391 | category: str = None, 392 | err_func: ErrFunc = None, 393 | ): 394 | client = _create_client( 395 | creds=creds, application_id=application_id, topic=topic, err_func=err_func 396 | ) 397 | 398 | aps_kwargs = {} 399 | if mutable_content: 400 | aps_kwargs["mutable-content"] = 1 401 | if category: 402 | aps_kwargs["category"] = category 403 | if content_available: 404 | aps_kwargs["content-available"] = 1 405 | 406 | requests = [ 407 | _create_notification_request_from_args( 408 | registration_id, 409 | alert, 410 | badge=badge, 411 | sound=sound, 412 | extra=extra, 413 | expiration=expiration, 414 | thread_id=thread_id, 415 | loc_key=loc_key, 416 | priority=priority, 417 | collapse_id=collapse_id, 418 | aps_kwargs=aps_kwargs, 419 | ) 420 | for registration_id in registration_ids 421 | ] 422 | 423 | send_requests = [_send_request(client, request) for request in requests] 424 | return await asyncio.gather(*send_requests) 425 | 426 | 427 | async def _send_request(apns, request): 428 | try: 429 | res = await asyncio.wait_for(apns.send_notification(request), timeout=1) 430 | return request.device_token, res 431 | except asyncio.TimeoutError: 432 | return request.device_token, NotificationResult( 433 | notification_id=request.notification_id, 434 | status="failed", 435 | description="TimeoutError", 436 | ) 437 | except: 438 | return request.device_token, NotificationResult( 439 | notification_id=request.notification_id, 440 | status="failed", 441 | description="CommunicationError", 442 | ) 443 | -------------------------------------------------------------------------------- /push_notifications/compat.py: -------------------------------------------------------------------------------- 1 | # flake8:noqa 2 | from urllib.error import HTTPError 3 | from urllib.parse import urlencode 4 | from urllib.request import Request, urlopen 5 | -------------------------------------------------------------------------------- /push_notifications/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS # noqa: I001 4 | from .app import AppConfig # noqa: F401 5 | from .appmodel import AppModelConfig # noqa: F401 6 | from .legacy import LegacyConfig # noqa: F401 7 | 8 | 9 | manager = None 10 | 11 | 12 | def get_manager(reload=False): 13 | global manager 14 | 15 | if not manager or reload is True: 16 | manager = import_string(SETTINGS["CONFIG"])() 17 | 18 | return manager 19 | 20 | 21 | # implementing get_manager as a function allows tests to reload settings 22 | get_manager() 23 | -------------------------------------------------------------------------------- /push_notifications/conf/app.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | from .base import BaseConfig, check_apns_certificate 5 | 6 | 7 | SETTING_MISMATCH = ( 8 | "Application '{application_id}' ({platform}) does not support the setting '{setting}'." 9 | ) 10 | 11 | # code can be "missing" or "invalid" 12 | BAD_PLATFORM = ( 13 | 'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["PLATFORM"] is {code}. ' 14 | "Must be one of: {platforms}." 15 | ) 16 | 17 | UNKNOWN_PLATFORM = ( 18 | "Unknown Platform: {platform}. Must be one of: {platforms}." 19 | ) 20 | 21 | MISSING_SETTING = ( 22 | 'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["{setting}"] is missing.' 23 | ) 24 | 25 | PLATFORMS = [ 26 | "APNS", 27 | "FCM", 28 | "WNS", 29 | "WP", 30 | ] 31 | 32 | # Settings that all applications must have 33 | REQUIRED_SETTINGS = [ 34 | "PLATFORM", 35 | ] 36 | 37 | # Settings that an application may have to enable optional features 38 | # these settings are stubs for registry support and have no effect on the operation 39 | # of the application at this time. 40 | OPTIONAL_SETTINGS = [ 41 | "APPLICATION_GROUP", "APPLICATION_SECRET" 42 | ] 43 | 44 | # Since we can have an auth key, combined with a auth key id and team id *or* 45 | # a certificate, we make these all optional, and then make sure we have one or 46 | # the other (group) of settings. 47 | APNS_SETTINGS_CERT_CREDS = "CERTIFICATE" 48 | 49 | # Subkeys for APNS_SETTINGS_AUTH_CREDS 50 | APNS_AUTH_CREDS_REQUIRED = ["AUTH_KEY_PATH", "AUTH_KEY_ID", "TEAM_ID"] 51 | APNS_AUTH_CREDS_OPTIONAL = ["CERTIFICATE", "ENCRYPTION_ALGORITHM", "TOKEN_LIFETIME"] 52 | 53 | APNS_OPTIONAL_SETTINGS = [ 54 | "USE_SANDBOX", "USE_ALTERNATIVE_PORT", "TOPIC" 55 | ] 56 | 57 | FCM_REQUIRED_SETTINGS = [] 58 | FCM_OPTIONAL_SETTINGS = [ 59 | "MAX_RECIPIENTS", "FIREBASE_APP" 60 | ] 61 | 62 | WNS_REQUIRED_SETTINGS = ["PACKAGE_SECURITY_ID", "SECRET_KEY"] 63 | WNS_OPTIONAL_SETTINGS = ["WNS_ACCESS_URL"] 64 | 65 | WP_REQUIRED_SETTINGS = ["PRIVATE_KEY", "CLAIMS"] 66 | WP_OPTIONAL_SETTINGS = ["ERROR_TIMEOUT", "POST_URL"] 67 | 68 | 69 | class AppConfig(BaseConfig): 70 | """ 71 | Supports any number of push notification enabled applications. 72 | """ 73 | 74 | def __init__(self, settings=None): 75 | # supports overriding the settings to be loaded. Will load from ..settings by default. 76 | self._settings = settings or SETTINGS 77 | 78 | # initialize APPLICATIONS to an empty collection 79 | self._settings.setdefault("APPLICATIONS", {}) 80 | 81 | # validate application configurations 82 | self._validate_applications(self._settings["APPLICATIONS"]) 83 | 84 | def _validate_applications(self, apps): 85 | """Validate the application collection""" 86 | for application_id, application_config in apps.items(): 87 | self._validate_config(application_id, application_config) 88 | 89 | application_config["APPLICATION_ID"] = application_id 90 | 91 | def _validate_config(self, application_id, application_config): 92 | platform = application_config.get("PLATFORM", None) 93 | 94 | # platform is not present 95 | if platform is None: 96 | raise ImproperlyConfigured( 97 | BAD_PLATFORM.format( 98 | application_id=application_id, 99 | code="required", 100 | platforms=", ".join(PLATFORMS) 101 | ) 102 | ) 103 | 104 | # platform is not a valid choice from PLATFORMS 105 | if platform not in PLATFORMS: 106 | raise ImproperlyConfigured( 107 | BAD_PLATFORM.format( 108 | application_id=application_id, 109 | code="invalid", 110 | platforms=", ".join(PLATFORMS) 111 | ) 112 | ) 113 | 114 | validate_fn = "_validate_{platform}_config".format(platform=platform).lower() 115 | 116 | if hasattr(self, validate_fn): 117 | getattr(self, validate_fn)(application_id, application_config) 118 | else: 119 | raise ImproperlyConfigured( 120 | UNKNOWN_PLATFORM.format( 121 | platform=platform, 122 | platforms=", ".join(PLATFORMS) 123 | ) 124 | ) 125 | 126 | def _validate_apns_config(self, application_id, application_config): 127 | allowed = REQUIRED_SETTINGS + OPTIONAL_SETTINGS + \ 128 | APNS_AUTH_CREDS_REQUIRED + \ 129 | APNS_AUTH_CREDS_OPTIONAL + \ 130 | APNS_OPTIONAL_SETTINGS 131 | 132 | self._validate_allowed_settings(application_id, application_config, allowed) 133 | # We have two sets of settings, certificate and JWT auth key. 134 | # Auth Key requires 3 values, so if that is set, that will take 135 | # precedence. If None are set, we will throw an error. 136 | has_cert_creds = APNS_SETTINGS_CERT_CREDS in \ 137 | application_config.keys() 138 | self.has_token_creds = True 139 | for token_setting in APNS_AUTH_CREDS_REQUIRED: 140 | if token_setting not in application_config.keys(): 141 | self.has_token_creds = False 142 | break 143 | 144 | if not has_cert_creds and not self.has_token_creds: 145 | raise ImproperlyConfigured( 146 | MISSING_SETTING.format( 147 | application_id=application_id, 148 | setting=(APNS_SETTINGS_CERT_CREDS, APNS_AUTH_CREDS_REQUIRED))) 149 | cert_path = None 150 | if has_cert_creds: 151 | cert_path = "CERTIFICATE" 152 | elif self.has_token_creds: 153 | cert_path = "AUTH_KEY_PATH" 154 | allowed_tokens = APNS_AUTH_CREDS_REQUIRED + \ 155 | APNS_AUTH_CREDS_OPTIONAL + \ 156 | APNS_OPTIONAL_SETTINGS + \ 157 | REQUIRED_SETTINGS 158 | self._validate_allowed_settings(application_id, application_config, allowed_tokens) 159 | self._validate_required_settings( 160 | application_id, application_config, APNS_AUTH_CREDS_REQUIRED 161 | ) 162 | self._validate_apns_certificate(application_config[cert_path]) 163 | 164 | # determine/set optional values 165 | application_config.setdefault("USE_SANDBOX", False) 166 | application_config.setdefault("USE_ALTERNATIVE_PORT", False) 167 | application_config.setdefault("TOPIC", None) 168 | 169 | def _validate_apns_certificate(self, certfile): 170 | """Validate the APNS certificate at startup.""" 171 | 172 | try: 173 | with open(certfile) as f: 174 | content = f.read() 175 | check_apns_certificate(content) 176 | except Exception as e: 177 | raise ImproperlyConfigured( 178 | "The APNS certificate file at {!r} is not readable: {}".format(certfile, e) 179 | ) 180 | 181 | def _validate_fcm_config(self, application_id, application_config): 182 | allowed = ( 183 | REQUIRED_SETTINGS + OPTIONAL_SETTINGS + FCM_REQUIRED_SETTINGS + FCM_OPTIONAL_SETTINGS 184 | ) 185 | 186 | self._validate_allowed_settings(application_id, application_config, allowed) 187 | self._validate_required_settings( 188 | application_id, application_config, FCM_REQUIRED_SETTINGS 189 | ) 190 | 191 | application_config.setdefault("FIREBASE_APP", None) 192 | application_config.setdefault("MAX_RECIPIENTS", 1000) 193 | 194 | def _validate_wns_config(self, application_id, application_config): 195 | allowed = ( 196 | REQUIRED_SETTINGS + OPTIONAL_SETTINGS + WNS_REQUIRED_SETTINGS + WNS_OPTIONAL_SETTINGS 197 | ) 198 | 199 | self._validate_allowed_settings(application_id, application_config, allowed) 200 | self._validate_required_settings( 201 | application_id, application_config, WNS_REQUIRED_SETTINGS 202 | ) 203 | 204 | application_config.setdefault("WNS_ACCESS_URL", "https://login.live.com/accesstoken.srf") 205 | 206 | def _validate_wp_config(self, application_id, application_config): 207 | allowed = ( 208 | REQUIRED_SETTINGS + OPTIONAL_SETTINGS + WP_REQUIRED_SETTINGS + WP_OPTIONAL_SETTINGS 209 | ) 210 | 211 | self._validate_allowed_settings(application_id, application_config, allowed) 212 | self._validate_required_settings( 213 | application_id, application_config, WP_REQUIRED_SETTINGS 214 | ) 215 | application_config.setdefault("POST_URL", { 216 | "CHROME": "https://fcm.googleapis.com/fcm/send", 217 | "OPERA": "https://fcm.googleapis.com/fcm/send", 218 | "EDGE": "https://wns2-par02p.notify.windows.com/w", 219 | "FIREFOX": "https://updates.push.services.mozilla.com/wpush/v2", 220 | }) 221 | 222 | def _validate_allowed_settings(self, application_id, application_config, allowed_settings): 223 | """Confirm only allowed settings are present.""" 224 | 225 | for setting_key in application_config.keys(): 226 | if setting_key not in allowed_settings: 227 | raise ImproperlyConfigured( 228 | "Platform {}, app {} does not support the setting: {}.".format( 229 | application_config["PLATFORM"], application_id, setting_key 230 | ) 231 | ) 232 | 233 | def _validate_required_settings( 234 | self, application_id, application_config, required_settings, 235 | should_throw=True 236 | ): 237 | """All required keys must be present""" 238 | 239 | for setting_key in required_settings: 240 | if setting_key not in application_config.keys(): 241 | if should_throw: 242 | raise ImproperlyConfigured( 243 | MISSING_SETTING.format( 244 | application_id=application_id, setting=setting_key 245 | ) 246 | ) 247 | else: 248 | return False 249 | return True 250 | 251 | def _get_application_settings(self, application_id, platform, settings_key): 252 | """ 253 | Walks through PUSH_NOTIFICATIONS_SETTINGS to find the correct setting value 254 | or raises ImproperlyConfigured. 255 | """ 256 | 257 | if not application_id: 258 | conf_cls = "push_notifications.conf.AppConfig" 259 | raise ImproperlyConfigured( 260 | "{} requires the application_id be specified at all times.".format(conf_cls) 261 | ) 262 | 263 | # verify that the application config exists 264 | app_config = self._settings.get("APPLICATIONS").get(application_id, None) 265 | if app_config is None: 266 | raise ImproperlyConfigured( 267 | "No application configured with application_id: {}.".format(application_id) 268 | ) 269 | 270 | # fetch a setting for the incorrect type of platform 271 | if app_config.get("PLATFORM") != platform: 272 | raise ImproperlyConfigured( 273 | SETTING_MISMATCH.format( 274 | application_id=application_id, 275 | platform=app_config.get("PLATFORM"), 276 | setting=settings_key 277 | ) 278 | ) 279 | 280 | # finally, try to fetch the setting 281 | if settings_key not in app_config: 282 | raise ImproperlyConfigured( 283 | MISSING_SETTING.format( 284 | application_id=application_id, setting=settings_key 285 | ) 286 | ) 287 | 288 | return app_config.get(settings_key) 289 | 290 | def get_firebase_app(self, application_id=None): 291 | return self._get_application_settings(application_id, "FCM", "FIREBASE_APP") 292 | 293 | def has_auth_token_creds(self, application_id=None): 294 | return self.has_token_creds 295 | 296 | def get_max_recipients(self, application_id=None): 297 | return self._get_application_settings(application_id, "FCM", "MAX_RECIPIENTS") 298 | 299 | def get_apns_certificate(self, application_id=None): 300 | r = self._get_application_settings(application_id, "APNS", "CERTIFICATE") 301 | if not isinstance(r, str): 302 | # probably the (Django) file, and file path should be got 303 | if hasattr(r, "path"): 304 | return r.path 305 | elif (hasattr(r, "has_key") or hasattr(r, "__contains__")) and "path" in r: 306 | return r["path"] 307 | else: 308 | raise ImproperlyConfigured( 309 | "The APNS certificate settings value should be a string, or " 310 | "should have a 'path' attribute or key" 311 | ) 312 | return r 313 | 314 | def get_apns_auth_creds(self, application_id=None): 315 | return \ 316 | (self._get_apns_auth_key_path(application_id), 317 | self._get_apns_auth_key_id(application_id), 318 | self._get_apns_team_id(application_id)) 319 | 320 | def _get_apns_auth_key_path(self, application_id=None): 321 | return self._get_application_settings(application_id, "APNS", "AUTH_KEY_PATH") 322 | 323 | def _get_apns_auth_key_id(self, application_id=None): 324 | return self._get_application_settings(application_id, "APNS", "AUTH_KEY_ID") 325 | 326 | def _get_apns_team_id(self, application_id=None): 327 | return self._get_application_settings(application_id, "APNS", "TEAM_ID") 328 | 329 | def get_apns_use_sandbox(self, application_id=None): 330 | return self._get_application_settings(application_id, "APNS", "USE_SANDBOX") 331 | 332 | def get_apns_use_alternative_port(self, application_id=None): 333 | return self._get_application_settings(application_id, "APNS", "USE_ALTERNATIVE_PORT") 334 | 335 | def get_apns_topic(self, application_id=None): 336 | return self._get_application_settings(application_id, "APNS", "TOPIC") 337 | 338 | def get_wns_package_security_id(self, application_id=None): 339 | return self._get_application_settings(application_id, "WNS", "PACKAGE_SECURITY_ID") 340 | 341 | def get_wns_secret_key(self, application_id=None): 342 | return self._get_application_settings(application_id, "WNS", "SECRET_KEY") 343 | 344 | def get_wp_post_url(self, application_id, browser): 345 | return self._get_application_settings(application_id, "WP", "POST_URL")[browser] 346 | 347 | def get_wp_private_key(self, application_id=None): 348 | return self._get_application_settings(application_id, "WP", "PRIVATE_KEY") 349 | 350 | def get_wp_claims(self, application_id=None): 351 | return self._get_application_settings(application_id, "WP", "CLAIMS") 352 | -------------------------------------------------------------------------------- /push_notifications/conf/appmodel.py: -------------------------------------------------------------------------------- 1 | from .base import BaseConfig 2 | 3 | 4 | class AppModelConfig(BaseConfig): 5 | """Future home of the Application Model conf adapter 6 | 7 | Supports multiple applications in the database. 8 | """ 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /push_notifications/conf/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | 4 | class BaseConfig: 5 | 6 | def get_firebase_app(self, application_id=None): 7 | raise NotImplementedError 8 | 9 | def has_auth_token_creds(self, application_id=None): 10 | raise NotImplementedError 11 | 12 | def get_apns_certificate(self, application_id=None): 13 | raise NotImplementedError 14 | 15 | def get_apns_auth_creds(self, application_id=None): 16 | raise NotImplementedError 17 | 18 | def get_apns_use_sandbox(self, application_id=None): 19 | raise NotImplementedError 20 | 21 | def get_apns_use_alternative_port(self, application_id=None): 22 | raise NotImplementedError 23 | 24 | def get_wns_package_security_id(self, application_id=None): 25 | raise NotImplementedError 26 | 27 | def get_wns_secret_key(self, application_id=None): 28 | raise NotImplementedError 29 | 30 | def get_max_recipients(self, application_id=None): 31 | raise NotImplementedError 32 | 33 | def get_applications(self): 34 | """Returns a collection containing the configured applications.""" 35 | 36 | raise NotImplementedError 37 | 38 | 39 | # This works for both the certificate and the auth key (since that's just 40 | # a certificate). 41 | def check_apns_certificate(ss): 42 | mode = "start" 43 | for s in ss.split("\n"): 44 | if mode == "start": 45 | if "BEGIN RSA PRIVATE KEY" in s or "BEGIN PRIVATE KEY" in s: 46 | mode = "key" 47 | elif mode == "key": 48 | if "END RSA PRIVATE KEY" in s or "END PRIVATE KEY" in s: 49 | mode = "end" 50 | break 51 | elif s.startswith("Proc-Type") and "ENCRYPTED" in s: 52 | raise ImproperlyConfigured("Encrypted APNS private keys are not supported") 53 | 54 | if mode != "end": 55 | raise ImproperlyConfigured("The APNS certificate doesn't contain a private key") 56 | -------------------------------------------------------------------------------- /push_notifications/conf/legacy.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | from .base import BaseConfig 5 | 6 | 7 | __all__ = [ 8 | "LegacyConfig" 9 | ] 10 | 11 | 12 | class empty: 13 | pass 14 | 15 | 16 | class LegacyConfig(BaseConfig): 17 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 18 | 19 | def _get_application_settings(self, application_id, settings_key, error_message): 20 | """Legacy behaviour""" 21 | 22 | if not application_id: 23 | value = SETTINGS.get(settings_key, empty) 24 | if value is empty: 25 | raise ImproperlyConfigured(error_message) 26 | return value 27 | else: 28 | msg = ( 29 | "LegacySettings does not support application_id. To enable " 30 | "multiple application support, use push_notifications.conf.AppSettings." 31 | ) 32 | raise ImproperlyConfigured(msg) 33 | 34 | def get_firebase_app(self, application_id=None): 35 | key = "FIREBASE_APP" 36 | msg = ( 37 | 'Set PUSH_NOTIFICATIONS_SETTINGS["{}"] to send messages through FCM.'.format(key) 38 | ) 39 | return self._get_application_settings(application_id, key, msg) 40 | 41 | def get_max_recipients(self, application_id=None): 42 | key = "FCM_MAX_RECIPIENTS" 43 | msg = ( 44 | 'Set PUSH_NOTIFICATIONS_SETTINGS["{}"] to send messages through FCM.'.format(key) 45 | ) 46 | return self._get_application_settings(application_id, key, msg) 47 | 48 | def has_auth_token_creds(self, application_id=None): 49 | try: 50 | self._get_apns_auth_key(application_id) 51 | self._get_apns_auth_key_id(application_id) 52 | self._get_apns_team_id(application_id) 53 | except ImproperlyConfigured: 54 | return False 55 | 56 | return True 57 | 58 | def get_apns_certificate(self, application_id=None): 59 | r = self._get_application_settings( 60 | application_id, "APNS_CERTIFICATE", 61 | "You need to setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 62 | ) 63 | if not isinstance(r, str): 64 | # probably the (Django) file, and file path should be got 65 | if hasattr(r, "path"): 66 | return r.path 67 | elif (hasattr(r, "has_key") or hasattr(r, "__contains__")) and "path" in r: 68 | return r["path"] 69 | else: 70 | msg = ( 71 | "The APNS certificate settings value should be a string, or " 72 | "should have a 'path' attribute or key" 73 | ) 74 | raise ImproperlyConfigured(msg) 75 | return r 76 | 77 | def get_apns_auth_creds(self, application_id=None): 78 | return ( 79 | self._get_apns_auth_key(application_id), 80 | self._get_apns_auth_key_id(application_id), 81 | self._get_apns_team_id(application_id)) 82 | 83 | def _get_apns_auth_key(self, application_id=None): 84 | return self._get_application_settings(application_id, "APNS_AUTH_KEY_PATH", self.msg) 85 | 86 | def _get_apns_team_id(self, application_id=None): 87 | return self._get_application_settings(application_id, "APNS_TEAM_ID", self.msg) 88 | 89 | def _get_apns_auth_key_id(self, application_id=None): 90 | return self._get_application_settings(application_id, "APNS_AUTH_KEY_ID", self.msg) 91 | 92 | def get_apns_use_sandbox(self, application_id=None): 93 | return self._get_application_settings(application_id, "APNS_USE_SANDBOX", self.msg) 94 | 95 | def get_apns_use_alternative_port(self, application_id=None): 96 | return self._get_application_settings(application_id, "APNS_USE_ALTERNATIVE_PORT", self.msg) 97 | 98 | def get_apns_topic(self, application_id=None): 99 | return self._get_application_settings(application_id, "APNS_TOPIC", self.msg) 100 | 101 | def get_apns_host(self, application_id=None): 102 | return self._get_application_settings(application_id, "APNS_HOST", self.msg) 103 | 104 | def get_apns_port(self, application_id=None): 105 | return self._get_application_settings(application_id, "APNS_PORT", self.msg) 106 | 107 | def get_apns_feedback_host(self, application_id=None): 108 | return self._get_application_settings(application_id, "APNS_FEEDBACK_HOST", self.msg) 109 | 110 | def get_apns_feedback_port(self, application_id=None): 111 | return self._get_application_settings(application_id, "APNS_FEEDBACK_PORT", self.msg) 112 | 113 | def get_wns_package_security_id(self, application_id=None): 114 | return self._get_application_settings(application_id, "WNS_PACKAGE_SECURITY_ID", self.msg) 115 | 116 | def get_wns_secret_key(self, application_id=None): 117 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 118 | return self._get_application_settings(application_id, "WNS_SECRET_KEY", msg) 119 | 120 | def get_wp_post_url(self, application_id, browser): 121 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 122 | return self._get_application_settings(application_id, "WP_POST_URL", msg)[browser] 123 | 124 | def get_wp_private_key(self, application_id=None): 125 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 126 | return self._get_application_settings(application_id, "WP_PRIVATE_KEY", msg) 127 | 128 | def get_wp_claims(self, application_id=None): 129 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 130 | return self._get_application_settings(application_id, "WP_CLAIMS", msg) 131 | -------------------------------------------------------------------------------- /push_notifications/exceptions.py: -------------------------------------------------------------------------------- 1 | class NotificationError(Exception): 2 | def __init__(self, message): 3 | super().__init__(message) 4 | self.message = message 5 | pass 6 | 7 | 8 | # APNS 9 | class APNSError(NotificationError): 10 | pass 11 | 12 | 13 | class APNSUnsupportedPriority(APNSError): 14 | pass 15 | 16 | 17 | class APNSServerError(APNSError): 18 | def __init__(self, status): 19 | super().__init__(status) 20 | self.status = status 21 | 22 | 23 | # GCM 24 | class GCMError(NotificationError): 25 | pass 26 | 27 | 28 | # Web Push 29 | class WebPushError(NotificationError): 30 | pass 31 | -------------------------------------------------------------------------------- /push_notifications/fields.py: -------------------------------------------------------------------------------- 1 | import re 2 | import struct 3 | 4 | from django import forms 5 | from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator 6 | from django.db import connection, models 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | __all__ = ["HexadecimalField", "HexIntegerField"] 11 | 12 | UNSIGNED_64BIT_INT_MIN_VALUE = 0 13 | UNSIGNED_64BIT_INT_MAX_VALUE = 2 ** 64 - 1 14 | 15 | 16 | hex_re = re.compile(r"^(0x)?([0-9a-f])+$", re.I) 17 | signed_integer_vendors = [ 18 | "postgresql", 19 | "sqlite", 20 | ] 21 | 22 | 23 | def _using_signed_storage(): 24 | return connection.vendor in signed_integer_vendors 25 | 26 | 27 | def _signed_to_unsigned_integer(value): 28 | return struct.unpack("Q", struct.pack("q", value))[0] 29 | 30 | 31 | def _unsigned_to_signed_integer(value): 32 | return struct.unpack("q", struct.pack("Q", value))[0] 33 | 34 | 35 | def _hex_string_to_unsigned_integer(value): 36 | return int(value, 16) 37 | 38 | 39 | def _unsigned_integer_to_hex_string(value): 40 | return hex(value).rstrip("L") 41 | 42 | 43 | class HexadecimalField(forms.CharField): 44 | """ 45 | A form field that accepts only hexadecimal numbers 46 | """ 47 | def __init__(self, *args, **kwargs): 48 | self.default_validators = [ 49 | RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid") 50 | ] 51 | super().__init__(*args, **kwargs) 52 | 53 | def prepare_value(self, value): 54 | # converts bigint from db to hex before it is displayed in admin 55 | if value and not isinstance(value, str) \ 56 | and connection.vendor in ("mysql", "sqlite"): 57 | value = _unsigned_integer_to_hex_string(value) 58 | return super(forms.CharField, self).prepare_value(value) 59 | 60 | 61 | class HexIntegerField(models.BigIntegerField): 62 | """ 63 | This field stores a hexadecimal *string* of up to 64 bits as an unsigned integer 64 | on *all* backends including postgres. 65 | 66 | Reasoning: Postgres only supports signed bigints. Since we don't care about 67 | signedness, we store it as signed, and cast it to unsigned when we deal with 68 | the actual value (with struct) 69 | 70 | On sqlite and mysql, native unsigned bigint types are used. In all cases, the 71 | value we deal with in python is always in hex. 72 | """ 73 | 74 | validators = [ 75 | MinValueValidator(UNSIGNED_64BIT_INT_MIN_VALUE), 76 | MaxValueValidator(UNSIGNED_64BIT_INT_MAX_VALUE) 77 | ] 78 | 79 | def db_type(self, connection): 80 | if "mysql" == connection.vendor: 81 | return "bigint unsigned" 82 | elif "sqlite" == connection.vendor: 83 | return "UNSIGNED BIG INT" 84 | else: 85 | return super().db_type(connection=connection) 86 | 87 | def get_prep_value(self, value): 88 | """ Return the integer value to be stored from the hex string """ 89 | if value is None or value == "": 90 | return None 91 | if isinstance(value, str): 92 | value = _hex_string_to_unsigned_integer(value) 93 | if _using_signed_storage(): 94 | value = _unsigned_to_signed_integer(value) 95 | return value 96 | 97 | def from_db_value(self, value, *args): 98 | """ Return an unsigned int representation from all db backends """ 99 | if value is None: 100 | return value 101 | if _using_signed_storage(): 102 | value = _signed_to_unsigned_integer(value) 103 | return value 104 | 105 | def to_python(self, value): 106 | """ Return a str representation of the hexadecimal """ 107 | if isinstance(value, str): 108 | return value 109 | if value is None: 110 | return value 111 | return _unsigned_integer_to_hex_string(value) 112 | 113 | def formfield(self, **kwargs): 114 | defaults = {"form_class": HexadecimalField} 115 | defaults.update(kwargs) 116 | # yes, that super call is right 117 | return super(models.IntegerField, self).formfield(**defaults) 118 | 119 | def run_validators(self, value): 120 | # make sure validation is performed on integer value not string value 121 | value = _hex_string_to_unsigned_integer(value) 122 | return super(models.BigIntegerField, self).run_validators(value) 123 | -------------------------------------------------------------------------------- /push_notifications/gcm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Firebase Cloud Messaging 3 | Previously known as GCM / C2DM 4 | Documentation is available on the Firebase Developer website: 5 | https://firebase.google.com/docs/cloud-messaging/ 6 | """ 7 | 8 | from copy import copy 9 | from typing import List, Union 10 | 11 | from firebase_admin import messaging 12 | from firebase_admin.exceptions import FirebaseError, InvalidArgumentError 13 | 14 | from .conf import get_manager 15 | 16 | 17 | # Valid keys for FCM messages. Reference: 18 | # https://firebase.google.com/docs/cloud-messaging/http-server-ref 19 | FCM_NOTIFICATIONS_PAYLOAD_KEYS = [ 20 | "title", "body", "icon", "image", "sound", "badge", "color", "tag", "click_action", 21 | "body_loc_key", "body_loc_args", "title_loc_key", "title_loc_args", "android_channel_id" 22 | ] 23 | 24 | 25 | def dict_to_fcm_message(data: dict, dry_run=False, **kwargs) -> messaging.Message: 26 | """ 27 | Constructs a messaging.Message from the old dictionary. 28 | 29 | FCM_NOTIFICATION_PAYLOAD_KEYS are being put into the AndroidNotification 30 | FCM_OPTIONS_KEYS are being put into the AndroidConfig 31 | FCM_TARGETS_KEYS is mapped to either topic, token or condition 32 | 33 | If dry_run is included and its value is True, no message will be returned, so nothing is accidentally sent. 34 | """ 35 | 36 | data = data.copy() 37 | 38 | # in the old version, dry run was being passed in the data dict 39 | # now it needs to be passed as an argument for the send_each method 40 | # to not accidentally sending messages, do not return a message here. 41 | if "dry_run" in data and data.pop("dry_run", False) or dry_run: 42 | return None 43 | 44 | android_notification = None 45 | 46 | notification_payload = {} 47 | if "message" in data: 48 | notification_payload["body"] = data.pop("message", None) 49 | for key in FCM_NOTIFICATIONS_PAYLOAD_KEYS: 50 | value_from_extra = data.pop(key, None) 51 | if value_from_extra: 52 | notification_payload[key] = value_from_extra 53 | value_from_kwargs = kwargs.pop(key, None) 54 | if value_from_kwargs: 55 | notification_payload[key] = value_from_kwargs 56 | if notification_payload: 57 | # channel id is the one that is different 58 | notification_payload["channel_id"] = notification_payload.pop("android_channel_id", None) 59 | notification_payload["notification_count"] = notification_payload.pop("badge", None) 60 | android_notification = messaging.AndroidNotification(**notification_payload) 61 | 62 | android_config = messaging.AndroidConfig( 63 | collapse_key=data.pop("collapse_key", None) or kwargs.get("collapse_key", None), 64 | priority=data.pop("priority", None) or kwargs.get("priority", None), 65 | ttl=data.pop("time_to_live", None) or kwargs.get("time_to_live", None), 66 | restricted_package_name=data.pop("restricted_package_name", None) or kwargs.get( 67 | "restricted_package_name", None), 68 | data=data, 69 | notification=android_notification 70 | ) 71 | 72 | message = messaging.Message(data=data, android=android_config) 73 | 74 | # set correct receiver 75 | to: str = data.pop("to", None) or kwargs.get("to", None) 76 | condition = data.pop("condition", None) or kwargs.get("condition", None) 77 | notification_key = data.pop( 78 | "notification_key", None) or kwargs.get("notification_key", None) 79 | 80 | # topic is set with /topic/ prefix, message can handle this format as well 81 | if to and to.startswith("/topic/"): 82 | message.topic = to 83 | else: 84 | message.token = notification_key or to 85 | message.condition = condition 86 | 87 | return message 88 | 89 | 90 | def _chunks(l, n): 91 | """ 92 | Yield successive chunks from list \a l with a maximum size \a n 93 | """ 94 | for i in range(0, len(l), n): 95 | yield l[i:i + n] 96 | 97 | 98 | # Error codes: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode 99 | fcm_error_list = [ 100 | messaging.UnregisteredError, 101 | messaging.SenderIdMismatchError, 102 | InvalidArgumentError, 103 | ] 104 | 105 | fcm_error_list_str = [x.code for x in fcm_error_list] 106 | 107 | 108 | def _validate_exception_for_deactivation(exc: Union[FirebaseError]) -> bool: 109 | if not exc: 110 | return False 111 | exc_type = type(exc) 112 | if exc_type == str: 113 | return exc in fcm_error_list_str 114 | return ( 115 | exc_type == InvalidArgumentError and exc.cause == "Invalid registration" 116 | ) or (exc_type in fcm_error_list) 117 | 118 | 119 | def _deactivate_devices_with_error_results( 120 | registration_ids: List[str], 121 | results: List[Union[messaging.SendResponse, messaging.ErrorInfo]], 122 | ) -> List[str]: 123 | if not results: 124 | return [] 125 | if isinstance(results[0], messaging.SendResponse): 126 | deactivated_ids = [ 127 | token 128 | for item, token in zip(results, registration_ids) 129 | if _validate_exception_for_deactivation(item.exception) 130 | ] 131 | else: 132 | deactivated_ids = [ 133 | registration_ids[x.index] 134 | for x in results 135 | if _validate_exception_for_deactivation(x.reason) 136 | ] 137 | from .models import GCMDevice 138 | GCMDevice.objects.filter(registration_id__in=deactivated_ids).update(active=False) 139 | return deactivated_ids 140 | 141 | 142 | def _prepare_message(message: messaging.Message, token: str): 143 | message.token = token 144 | return copy(message) 145 | 146 | 147 | def send_message( 148 | registration_ids, 149 | message: messaging.Message, 150 | application_id=None, 151 | dry_run=False, 152 | **kwargs 153 | ): 154 | """ 155 | Sends an FCM notification to one or more registration_ids. The registration_ids 156 | can be a list or a single string. 157 | 158 | :param registration_ids: A list of registration ids or a single string 159 | :param message: The Message object, use `dict_to_fcm_message` to convert dict to Message 160 | :param application_id: The application id to use. 161 | :param dry_run: If True, no message will be sent. 162 | 163 | :return: A BatchResponse object 164 | """ 165 | max_recipients = get_manager().get_max_recipients(application_id) 166 | app = get_manager().get_firebase_app(application_id) if application_id else None 167 | 168 | # Checks for valid recipient 169 | if registration_ids is None and message.topic is None and message.condition is None: 170 | return 171 | 172 | # Bundles the registration_ids in an list if only one is sent 173 | if not isinstance(registration_ids, list): 174 | registration_ids = [registration_ids] if registration_ids else None 175 | 176 | # FCM only allows up to 1000 reg ids per bulk message 177 | # https://firebase.google.com/docs/cloud-messaging/server#http-request 178 | if registration_ids: 179 | ret: List[messaging.SendResponse] = [] 180 | for chunk in _chunks(registration_ids, max_recipients): 181 | messages = [ 182 | _prepare_message(message, token) for token in chunk 183 | ] 184 | responses = messaging.send_each(messages, dry_run=dry_run, app=app).responses 185 | ret.extend(responses) 186 | _deactivate_devices_with_error_results(registration_ids, ret) 187 | return messaging.BatchResponse(ret) 188 | else: 189 | return messaging.BatchResponse([]) 190 | 191 | 192 | send_bulk_message = send_message 193 | -------------------------------------------------------------------------------- /push_notifications/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | import push_notifications.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='APNSDevice', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), 19 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 20 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), 21 | ('device_id', models.UUIDField(help_text='UDID / UIDevice.identifierForVendor()', max_length=32, null=True, verbose_name='Device ID', blank=True, db_index=True)), 22 | ('registration_id', models.CharField(unique=True, max_length=64, verbose_name='Registration ID')), 23 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 24 | ], 25 | options={ 26 | 'verbose_name': 'APNS device', 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='GCMDevice', 32 | fields=[ 33 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 34 | ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), 35 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 36 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), 37 | ('device_id', push_notifications.fields.HexIntegerField(help_text='ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)', null=True, verbose_name='Device ID', blank=True, db_index=True)), 38 | ('registration_id', models.TextField(verbose_name='Registration ID')), 39 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 40 | ], 41 | options={ 42 | 'verbose_name': 'GCM device', 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /push_notifications/migrations/0002_auto_20160106_0850.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.1 on 2016-01-06 08:50 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('push_notifications', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='apnsdevice', 14 | name='registration_id', 15 | field=models.CharField(max_length=200, unique=True, verbose_name='Registration ID'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /push_notifications/migrations/0003_wnsdevice.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.6 on 2016-06-13 20:46 2 | import django.db.models.deletion 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('push_notifications', '0002_auto_20160106_0850'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='WNSDevice', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Name')), 20 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 21 | ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Creation date')), 22 | ('device_id', models.UUIDField(blank=True, db_index=True, help_text='GUID()', null=True, verbose_name='Device ID')), 23 | ('registration_id', models.TextField(verbose_name='Notification URI')), 24 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | options={ 27 | 'verbose_name': 'WNS device', 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /push_notifications/migrations/0004_fcm.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.6 on 2016-06-13 20:46 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ('push_notifications', '0003_wnsdevice'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='gcmdevice', 16 | name='cloud_message_type', 17 | field=models.CharField(choices=[('FCM', 'Firebase Cloud Message'), ('GCM', 'Google Cloud Message')], default='GCM', help_text='You should choose FCM or GCM', max_length=3, verbose_name='Cloud Message Type') 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /push_notifications/migrations/0005_applicationid.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('push_notifications', '0004_fcm'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='apnsdevice', 14 | name='application_id', 15 | field=models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True), 16 | preserve_default=True, 17 | ), 18 | migrations.AddField( 19 | model_name='gcmdevice', 20 | name='application_id', 21 | field=models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True), 22 | preserve_default=True, 23 | ), 24 | migrations.AddField( 25 | model_name='wnsdevice', 26 | name='application_id', 27 | field=models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True), 28 | preserve_default=True, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /push_notifications/migrations/0006_webpushdevice.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 9 | ('push_notifications', '0005_applicationid'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='WebPushDevice', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=255, null=True, verbose_name='Name', blank=True)), 18 | ('active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications', verbose_name='Is active')), 19 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date', null=True)), 20 | ('application_id', models.CharField(help_text='Opaque application identity, should be filled in for multiple key/certificate access', max_length=64, null=True, verbose_name='Application ID', blank=True)), 21 | ('registration_id', models.TextField(verbose_name='Registration ID')), 22 | ('p256dh', models.CharField(max_length=88, verbose_name='User public encryption key')), 23 | ('auth', models.CharField(max_length=24, verbose_name='User auth secret')), 24 | ('browser', models.CharField(default='CHROME', help_text='Currently only support to Chrome, Firefox and Opera browsers', max_length=10, verbose_name='Browser', choices=[('CHROME', 'Chrome'), ('FIREFOX', 'Firefox'), ('OPERA', 'Opera')])), 25 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 26 | ], 27 | options={ 28 | 'verbose_name': 'WebPush device', 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /push_notifications/migrations/0007_uniquesetting.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('push_notifications', '0006_webpushdevice'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='apnsdevice', 15 | name='registration_id', 16 | field=models.CharField(max_length=200, unique=SETTINGS['UNIQUE_REG_ID'], verbose_name='Registration ID'), 17 | ), 18 | migrations.AlterField( 19 | model_name='gcmdevice', 20 | name='registration_id', 21 | field=models.TextField(unique=SETTINGS['UNIQUE_REG_ID'], verbose_name='Registration ID'), 22 | ), 23 | migrations.AlterField( 24 | model_name='webpushdevice', 25 | name='registration_id', 26 | field=models.TextField(unique=SETTINGS['UNIQUE_REG_ID'], verbose_name='Registration ID'), 27 | ), 28 | migrations.AlterField( 29 | model_name='wnsdevice', 30 | name='registration_id', 31 | field=models.TextField(unique=SETTINGS['UNIQUE_REG_ID'], verbose_name='Notification URI'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /push_notifications/migrations/0008_webpush_add_edge.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-12 09:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('push_notifications', '0007_uniquesetting'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='webpushdevice', 15 | name='browser', 16 | field=models.CharField(choices=[('CHROME', 'Chrome'), ('FIREFOX', 'Firefox'), ('OPERA', 'Opera'), ('EDGE', 'Edge')], default='CHROME', help_text='Currently only support to Chrome, Firefox, Edge and Opera browsers', max_length=10, verbose_name='Browser'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /push_notifications/migrations/0009_alter_apnsdevice_device_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-01-10 09:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('push_notifications', '0008_webpush_add_edge'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='apnsdevice', 15 | name='device_id', 16 | field=models.UUIDField(blank=True, db_index=True, help_text='UUID / UIDevice.identifierForVendor()', null=True, verbose_name='Device ID'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /push_notifications/migrations/0010_alter_gcmdevice_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-04-10 11:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('push_notifications', '0009_alter_apnsdevice_device_id'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='gcmdevice', 15 | options={'verbose_name': 'FCM device'}, 16 | ), 17 | migrations.AlterField( 18 | model_name='gcmdevice', 19 | name='cloud_message_type', 20 | field=models.CharField(choices=[('FCM', 'Firebase Cloud Message'), ('GCM', 'Google Cloud Message')], default='FCM', help_text='You should choose FCM, GCM is deprecated', max_length=3, verbose_name='Cloud Message Type'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /push_notifications/migrations/0011_alter_apnsdevice_registration_id.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('push_notifications', '0010_alter_gcmdevice_options_and_more'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='apnsdevice', 14 | name='registration_id', 15 | field=models.CharField(db_index=not SETTINGS['UNIQUE_REG_ID'], unique=SETTINGS['UNIQUE_REG_ID'], max_length=200, verbose_name='Registration ID'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /push_notifications/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-push-notifications/16d43c2f3b7593f288132731cc98cd3061bea54d/push_notifications/migrations/__init__.py -------------------------------------------------------------------------------- /push_notifications/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .fields import HexIntegerField 5 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 6 | 7 | 8 | CLOUD_MESSAGE_TYPES = ( 9 | ("FCM", "Firebase Cloud Message"), 10 | ("GCM", "Google Cloud Message"), 11 | ) 12 | 13 | BROWSER_TYPES = ( 14 | ("CHROME", "Chrome"), 15 | ("FIREFOX", "Firefox"), 16 | ("OPERA", "Opera"), 17 | ("EDGE", "Edge") 18 | ) 19 | 20 | 21 | class Device(models.Model): 22 | name = models.CharField(max_length=255, verbose_name=_("Name"), blank=True, null=True) 23 | active = models.BooleanField( 24 | verbose_name=_("Is active"), default=True, 25 | help_text=_("Inactive devices will not be sent notifications") 26 | ) 27 | user = models.ForeignKey( 28 | SETTINGS["USER_MODEL"], blank=True, null=True, on_delete=models.CASCADE 29 | ) 30 | date_created = models.DateTimeField( 31 | verbose_name=_("Creation date"), auto_now_add=True, null=True 32 | ) 33 | application_id = models.CharField( 34 | max_length=64, verbose_name=_("Application ID"), 35 | help_text=_( 36 | "Opaque application identity, should be filled in for multiple" 37 | " key/certificate access" 38 | ), 39 | blank=True, null=True 40 | ) 41 | 42 | class Meta: 43 | abstract = True 44 | 45 | def __str__(self): 46 | return ( 47 | self.name or 48 | str(self.device_id or "") or 49 | "{} for {}".format(self.__class__.__name__, self.user or "unknown user") 50 | ) 51 | 52 | 53 | class GCMDeviceManager(models.Manager): 54 | def get_queryset(self): 55 | return GCMDeviceQuerySet(self.model) 56 | 57 | 58 | class GCMDeviceQuerySet(models.query.QuerySet): 59 | def send_message(self, message, **kwargs): 60 | if self.exists(): 61 | from .gcm import dict_to_fcm_message, messaging 62 | from .gcm import send_message as fcm_send_message 63 | 64 | if not isinstance(message, messaging.Message): 65 | data = kwargs.pop("extra", {}) 66 | if message is not None: 67 | data["message"] = message 68 | # transform legacy data to new message object 69 | message = dict_to_fcm_message(data, **kwargs) 70 | 71 | app_ids = self.filter(active=True).order_by( 72 | "application_id" 73 | ).values_list("application_id", flat=True).distinct() 74 | 75 | responses = [] 76 | for app_id in app_ids: 77 | reg_ids = list( 78 | self.filter( 79 | active=True, cloud_message_type="FCM", application_id=app_id).values_list( 80 | "registration_id", flat=True 81 | ) 82 | ) 83 | if reg_ids: 84 | r = fcm_send_message(reg_ids, message, application_id=app_id, **kwargs) 85 | responses.extend(r.responses) 86 | 87 | return messaging.BatchResponse(responses) 88 | 89 | 90 | class GCMDevice(Device): 91 | # device_id cannot be a reliable primary key as fragmentation between different devices 92 | # can make it turn out to be null and such: 93 | # http://android-developers.blogspot.co.uk/2011/03/identifying-app-installations.html 94 | device_id = HexIntegerField( 95 | verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 96 | help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)") 97 | ) 98 | registration_id = models.TextField(verbose_name=_("Registration ID"), unique=SETTINGS["UNIQUE_REG_ID"]) 99 | cloud_message_type = models.CharField( 100 | verbose_name=_("Cloud Message Type"), max_length=3, 101 | choices=CLOUD_MESSAGE_TYPES, default="FCM", 102 | help_text=_("You should choose FCM, GCM is deprecated") 103 | ) 104 | objects = GCMDeviceManager() 105 | 106 | class Meta: 107 | verbose_name = _("FCM device") 108 | 109 | def send_message(self, message, **kwargs): 110 | from .gcm import dict_to_fcm_message, messaging 111 | from .gcm import send_message as fcm_send_message 112 | 113 | # GCM is not supported. 114 | if self.cloud_message_type == "GCM": 115 | return 116 | 117 | if not isinstance(message, messaging.Message): 118 | data = kwargs.pop("extra", {}) 119 | if message is not None: 120 | data["message"] = message 121 | # transform legacy data to new message object 122 | message = dict_to_fcm_message(data, **kwargs) 123 | 124 | return fcm_send_message( 125 | self.registration_id, message, 126 | application_id=self.application_id, **kwargs 127 | ) 128 | 129 | 130 | class APNSDeviceManager(models.Manager): 131 | def get_queryset(self): 132 | return APNSDeviceQuerySet(self.model) 133 | 134 | 135 | class APNSDeviceQuerySet(models.query.QuerySet): 136 | def send_message(self, message, creds=None, **kwargs): 137 | if self.exists(): 138 | try: 139 | from .apns_async import apns_send_bulk_message 140 | except ImportError: 141 | from .apns import apns_send_bulk_message 142 | 143 | app_ids = self.filter(active=True).order_by("application_id") \ 144 | .values_list("application_id", flat=True).distinct() 145 | res = [] 146 | for app_id in app_ids: 147 | reg_ids = list(self.filter(active=True, application_id=app_id).values_list( 148 | "registration_id", flat=True) 149 | ) 150 | r = apns_send_bulk_message( 151 | registration_ids=reg_ids, alert=message, application_id=app_id, 152 | creds=creds, **kwargs 153 | ) 154 | if hasattr(r, "keys"): 155 | res += [r] 156 | elif hasattr(r, "__getitem__"): 157 | res += r 158 | return res 159 | 160 | 161 | class APNSDevice(Device): 162 | device_id = models.UUIDField( 163 | verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 164 | help_text=_("UUID / UIDevice.identifierForVendor()") 165 | ) 166 | registration_id = models.CharField( 167 | verbose_name=_("Registration ID"), max_length=200, 168 | db_index=not SETTINGS["UNIQUE_REG_ID"], 169 | unique=SETTINGS["UNIQUE_REG_ID"], 170 | ) 171 | 172 | objects = APNSDeviceManager() 173 | 174 | class Meta: 175 | verbose_name = _("APNS device") 176 | 177 | def send_message(self, message, creds=None, **kwargs): 178 | try: 179 | from .apns_async import apns_send_message 180 | except ImportError: 181 | from .apns import apns_send_message 182 | 183 | return apns_send_message( 184 | registration_id=self.registration_id, 185 | alert=message, 186 | application_id=self.application_id, creds=creds, 187 | **kwargs 188 | ) 189 | 190 | 191 | class WNSDeviceManager(models.Manager): 192 | def get_queryset(self): 193 | return WNSDeviceQuerySet(self.model) 194 | 195 | 196 | class WNSDeviceQuerySet(models.query.QuerySet): 197 | def send_message(self, message, **kwargs): 198 | from .wns import wns_send_bulk_message 199 | 200 | app_ids = self.filter(active=True).order_by("application_id").values_list( 201 | "application_id", flat=True 202 | ).distinct() 203 | res = [] 204 | for app_id in app_ids: 205 | reg_ids = self.filter(active=True, application_id=app_id).values_list( 206 | "registration_id", flat=True 207 | ) 208 | r = wns_send_bulk_message(uri_list=list(reg_ids), message=message, **kwargs) 209 | if hasattr(r, "keys"): 210 | res += [r] 211 | elif hasattr(r, "__getitem__"): 212 | res += r 213 | 214 | return res 215 | 216 | 217 | class WNSDevice(Device): 218 | device_id = models.UUIDField( 219 | verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 220 | help_text=_("GUID()") 221 | ) 222 | registration_id = models.TextField(verbose_name=_("Notification URI"), unique=SETTINGS["UNIQUE_REG_ID"]) 223 | 224 | objects = WNSDeviceManager() 225 | 226 | class Meta: 227 | verbose_name = _("WNS device") 228 | 229 | def send_message(self, message, **kwargs): 230 | from .wns import wns_send_message 231 | 232 | return wns_send_message( 233 | uri=self.registration_id, message=message, application_id=self.application_id, 234 | **kwargs 235 | ) 236 | 237 | 238 | class WebPushDeviceManager(models.Manager): 239 | def get_queryset(self): 240 | return WebPushDeviceQuerySet(self.model) 241 | 242 | 243 | class WebPushDeviceQuerySet(models.query.QuerySet): 244 | def send_message(self, message, **kwargs): 245 | devices = self.filter(active=True).order_by("application_id").distinct() 246 | res = [] 247 | for device in devices: 248 | res.append(device.send_message(message)) 249 | 250 | return res 251 | 252 | 253 | class WebPushDevice(Device): 254 | registration_id = models.TextField(verbose_name=_("Registration ID"), unique=SETTINGS["UNIQUE_REG_ID"]) 255 | p256dh = models.CharField( 256 | verbose_name=_("User public encryption key"), 257 | max_length=88) 258 | auth = models.CharField( 259 | verbose_name=_("User auth secret"), 260 | max_length=24) 261 | browser = models.CharField( 262 | verbose_name=_("Browser"), max_length=10, 263 | choices=BROWSER_TYPES, default=BROWSER_TYPES[0][0], 264 | help_text=_("Currently only support to Chrome, Firefox, Edge and Opera browsers") 265 | ) 266 | objects = WebPushDeviceManager() 267 | 268 | class Meta: 269 | verbose_name = _("WebPush device") 270 | 271 | @property 272 | def device_id(self): 273 | return None 274 | 275 | def send_message(self, message, **kwargs): 276 | from .webpush import webpush_send_message 277 | 278 | return webpush_send_message(self, message, **kwargs) 279 | -------------------------------------------------------------------------------- /push_notifications/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | PUSH_NOTIFICATIONS_SETTINGS = getattr(settings, "PUSH_NOTIFICATIONS_SETTINGS", {}) 5 | 6 | PUSH_NOTIFICATIONS_SETTINGS.setdefault( 7 | "CONFIG", "push_notifications.conf.LegacyConfig" 8 | ) 9 | 10 | # FCM 11 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("FIREBASE_APP", None) 12 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("FCM_MAX_RECIPIENTS", 1000) 13 | 14 | # APNS 15 | if settings.DEBUG: 16 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_USE_SANDBOX", True) 17 | else: 18 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_USE_SANDBOX", False) 19 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_USE_ALTERNATIVE_PORT", False) 20 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("APNS_TOPIC", None) 21 | 22 | # WNS 23 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WNS_PACKAGE_SECURITY_ID", None) 24 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WNS_SECRET_KEY", None) 25 | PUSH_NOTIFICATIONS_SETTINGS.setdefault( 26 | "WNS_ACCESS_URL", "https://login.live.com/accesstoken.srf" 27 | ) 28 | 29 | # WP (WebPush) 30 | 31 | PUSH_NOTIFICATIONS_SETTINGS.setdefault( 32 | "FCM_POST_URL", "https://fcm.googleapis.com/fcm/send" 33 | ) 34 | 35 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_POST_URL", { 36 | "CHROME": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], 37 | "OPERA": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], 38 | "FIREFOX": "https://updates.push.services.mozilla.com/wpush/v2", 39 | "EDGE": "https://wns2-par02p.notify.windows.com/w", 40 | }) 41 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_PRIVATE_KEY", None) 42 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_CLAIMS", None) 43 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_ERROR_TIMEOUT", None) 44 | 45 | # User model 46 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("USER_MODEL", settings.AUTH_USER_MODEL) 47 | 48 | # Unique registration ID for all devices 49 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("UNIQUE_REG_ID", False) 50 | 51 | # API endpoint settings 52 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("UPDATE_ON_DUPLICATE_REG_ID", False) 53 | -------------------------------------------------------------------------------- /push_notifications/webpush.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from pywebpush import WebPushException, webpush 4 | 5 | from .conf import get_manager 6 | from .exceptions import WebPushError 7 | 8 | 9 | def get_subscription_info(application_id, uri, browser, auth, p256dh): 10 | if uri.startswith("https://"): 11 | endpoint = uri 12 | else: 13 | url = get_manager().get_wp_post_url(application_id, browser) 14 | endpoint = "{}/{}".format(url, uri) 15 | warnings.warn( 16 | "registration_id should be the full endpoint returned from pushManager.subscribe", 17 | DeprecationWarning, 18 | stacklevel=2, 19 | ) 20 | return { 21 | "endpoint": endpoint, 22 | "keys": { 23 | "auth": auth, 24 | "p256dh": p256dh, 25 | } 26 | } 27 | 28 | 29 | def webpush_send_message(device, message, **kwargs): 30 | subscription_info = get_subscription_info( 31 | device.application_id, device.registration_id, 32 | device.browser, device.auth, device.p256dh) 33 | try: 34 | results = {"results": [{"original_registration_id": device.registration_id}]} 35 | response = webpush( 36 | subscription_info=subscription_info, 37 | data=message, 38 | vapid_private_key=get_manager().get_wp_private_key(device.application_id), 39 | vapid_claims=get_manager().get_wp_claims(device.application_id).copy(), 40 | **kwargs 41 | ) 42 | if response.ok: 43 | results["success"] = 1 44 | else: 45 | results["failure"] = 1 46 | results["results"][0]["error"] = response.content 47 | return results 48 | except WebPushException as e: 49 | if e.response is not None and e.response.status_code in [404, 410]: 50 | results["failure"] = 1 51 | results["results"][0]["error"] = e.message 52 | device.active = False 53 | device.save() 54 | return results 55 | raise WebPushError(e.message) 56 | -------------------------------------------------------------------------------- /push_notifications/wns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Windows Notification Service 3 | 4 | Documentation is available on the Windows Dev Center: 5 | https://msdn.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-windows-push-notification-services--wns--overview 6 | """ 7 | 8 | import json 9 | import xml.etree.ElementTree as ET 10 | 11 | from django.core.exceptions import ImproperlyConfigured 12 | 13 | from .compat import HTTPError, Request, urlencode, urlopen 14 | from .conf import get_manager 15 | from .exceptions import NotificationError 16 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 17 | 18 | 19 | class WNSError(NotificationError): 20 | pass 21 | 22 | 23 | class WNSAuthenticationError(WNSError): 24 | pass 25 | 26 | 27 | class WNSNotificationResponseError(WNSError): 28 | pass 29 | 30 | 31 | def _wns_authenticate(scope="notify.windows.com", application_id=None): 32 | """ 33 | Requests an Access token for WNS communication. 34 | 35 | :return: dict: {'access_token': , 'expires_in': , 'token_type': 'bearer'} 36 | """ 37 | client_id = get_manager().get_wns_package_security_id(application_id) 38 | client_secret = get_manager().get_wns_secret_key(application_id) 39 | if not client_id: 40 | raise ImproperlyConfigured( 41 | 'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_PACKAGE_SECURITY_ID"] to use WNS.' 42 | ) 43 | 44 | if not client_secret: 45 | raise ImproperlyConfigured( 46 | 'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_SECRET_KEY"] to use WNS.' 47 | ) 48 | 49 | headers = { 50 | "Content-Type": "application/x-www-form-urlencoded", 51 | } 52 | params = { 53 | "grant_type": "client_credentials", 54 | "client_id": client_id, 55 | "client_secret": client_secret, 56 | "scope": scope, 57 | } 58 | data = urlencode(params).encode("utf-8") 59 | 60 | request = Request(SETTINGS["WNS_ACCESS_URL"], data=data, headers=headers) 61 | try: 62 | response = urlopen(request) 63 | except HTTPError as err: 64 | if err.code == 400: 65 | # One of your settings is probably jacked up. 66 | # https://msdn.microsoft.com/en-us/library/windows/apps/xaml/hh868245 67 | raise WNSAuthenticationError("Authentication failed, check your WNS settings.") 68 | raise err 69 | 70 | oauth_data = response.read().decode("utf-8") 71 | try: 72 | oauth_data = json.loads(oauth_data) 73 | except Exception: 74 | # Upstream WNS issue 75 | raise WNSAuthenticationError("Received invalid JSON data from WNS.") 76 | 77 | access_token = oauth_data.get("access_token") 78 | if not access_token: 79 | # Upstream WNS issue 80 | raise WNSAuthenticationError("Access token missing from WNS response.") 81 | 82 | return access_token 83 | 84 | 85 | def _wns_send(uri, data, wns_type="wns/toast", application_id=None): 86 | """ 87 | Sends a notification data and authentication to WNS. 88 | 89 | :param uri: str: The device's unique notification URI 90 | :param data: dict: The notification data to be sent. 91 | :return: 92 | """ 93 | access_token = _wns_authenticate(application_id=application_id) 94 | 95 | content_type = "text/xml" 96 | if wns_type == "wns/raw": 97 | content_type = "application/octet-stream" 98 | 99 | headers = { 100 | # content_type is "text/xml" (toast/badge/tile) | "application/octet-stream" (raw) 101 | "Content-Type": content_type, 102 | "Authorization": "Bearer %s" % (access_token), 103 | "X-WNS-Type": wns_type, # wns/toast | wns/badge | wns/tile | wns/raw 104 | } 105 | 106 | if type(data) is str: 107 | data = data.encode("utf-8") 108 | 109 | request = Request(uri, data, headers) 110 | 111 | # A lot of things can happen, let them know which one. 112 | try: 113 | response = urlopen(request) 114 | except HTTPError as err: 115 | if err.code == 400: 116 | msg = "One or more headers were specified incorrectly or conflict with another header." 117 | elif err.code == 401: 118 | msg = "The cloud service did not present a valid authentication ticket." 119 | elif err.code == 403: 120 | msg = "The cloud service is not authorized to send a notification to this URI." 121 | elif err.code == 404: 122 | msg = "The channel URI is not valid or is not recognized by WNS." 123 | elif err.code == 405: 124 | msg = "Invalid method. Only POST or DELETE is allowed." 125 | elif err.code == 406: 126 | msg = "The cloud service exceeded its throttle limit" 127 | elif err.code == 410: 128 | msg = "The channel expired." 129 | elif err.code == 413: 130 | msg = "The notification payload exceeds the 500 byte limit." 131 | elif err.code == 500: 132 | msg = "An internal failure caused notification delivery to fail." 133 | elif err.code == 503: 134 | msg = "The server is currently unavailable." 135 | else: 136 | raise err 137 | raise WNSNotificationResponseError("HTTP %i: %s" % (err.code, msg)) 138 | 139 | return response.read().decode("utf-8") 140 | 141 | 142 | def _wns_prepare_toast(data, **kwargs): 143 | """ 144 | Creates the xml tree for a `toast` notification 145 | 146 | :param data: dict: The notification data to be converted to an xml tree. 147 | 148 | { 149 | "text": ["Title text", "Message Text", "Another message!"], 150 | "image": ["src1", "src2"], 151 | } 152 | 153 | :return: str 154 | """ 155 | root = ET.Element("toast") 156 | visual = ET.SubElement(root, "visual") 157 | binding = ET.SubElement(visual, "binding") 158 | binding.attrib["template"] = kwargs.pop("template", "ToastText01") 159 | if "text" in data: 160 | for count, item in enumerate(data["text"], start=1): 161 | elem = ET.SubElement(binding, "text") 162 | elem.text = item 163 | elem.attrib["id"] = str(count) 164 | if "image" in data: 165 | for count, item in enumerate(data["image"], start=1): 166 | elem = ET.SubElement(binding, "img") 167 | elem.attrib["src"] = item 168 | elem.attrib["id"] = str(count) 169 | return ET.tostring(root) 170 | 171 | 172 | def wns_send_message( 173 | uri, message=None, xml_data=None, raw_data=None, application_id=None, **kwargs 174 | ): 175 | """ 176 | Sends a notification request to WNS. 177 | There are four notification types that WNS can send: toast, tile, badge and raw. 178 | Toast, tile, and badge can all be customized to use different 179 | templates/icons/sounds/launch params/etc. 180 | See docs for more information: 181 | https://msdn.microsoft.com/en-us/library/windows/apps/br212853.aspx 182 | 183 | There are multiple ways to input notification data: 184 | 185 | 1. The simplest and least custom notification to send is to just pass a string 186 | to `message`. This will create a toast notification with one text element. e.g.: 187 | "This is my notification title" 188 | 189 | 2. You can also pass a dictionary to `message`: it can only contain one or both 190 | keys: ["text", "image"]. The value of each key must be a list with the text and 191 | src respectively. e.g.: 192 | { 193 | "text": ["text1", "text2"], 194 | "image": ["src1", "src2"], 195 | } 196 | 197 | 3. Passing a dictionary to `xml_data` will create one of three types of 198 | notifications depending on the dictionary data (toast, tile, badge). 199 | See `dict_to_xml_schema` docs for more information on dictionary formatting. 200 | 201 | 4. Passing a value to `raw_data` will create a `raw` notification and send the 202 | input data as is. 203 | 204 | :param uri: str: The device's unique notification uri. 205 | :param message: str|dict: The notification data to be sent. 206 | :param xml_data: dict: A dictionary containing data to be converted to an xml tree. 207 | :param raw_data: str: Data to be sent via a `raw` notification. 208 | """ 209 | # Create a simple toast notification 210 | if message: 211 | wns_type = "wns/toast" 212 | if isinstance(message, str): 213 | message = { 214 | "text": [message, ], 215 | } 216 | prepared_data = _wns_prepare_toast(data=message, **kwargs) 217 | # Create a toast/tile/badge notification from a dictionary 218 | elif xml_data: 219 | xml = dict_to_xml_schema(xml_data) 220 | wns_type = "wns/%s" % xml.tag 221 | prepared_data = ET.tostring(xml) 222 | # Create a raw notification 223 | elif raw_data: 224 | wns_type = "wns/raw" 225 | prepared_data = raw_data 226 | else: 227 | raise TypeError( 228 | "At least one of the following parameters must be set:" 229 | "`message`, `xml_data`, `raw_data`" 230 | ) 231 | 232 | return _wns_send( 233 | uri=uri, data=prepared_data, wns_type=wns_type, application_id=application_id 234 | ) 235 | 236 | 237 | def wns_send_bulk_message( 238 | uri_list, message=None, xml_data=None, raw_data=None, application_id=None, **kwargs 239 | ): 240 | """ 241 | WNS doesn't support bulk notification, so we loop through each uri. 242 | 243 | :param uri_list: list: A list of uris the notification will be sent to. 244 | :param message: str: The notification data to be sent. 245 | :param xml_data: dict: A dictionary containing data to be converted to an xml tree. 246 | :param raw_data: str: Data to be sent via a `raw` notification. 247 | """ 248 | res = [] 249 | if uri_list: 250 | for uri in uri_list: 251 | r = wns_send_message( 252 | uri=uri, message=message, xml_data=xml_data, 253 | raw_data=raw_data, application_id=application_id, **kwargs 254 | ) 255 | res.append(r) 256 | return res 257 | 258 | 259 | def dict_to_xml_schema(data): 260 | """ 261 | Input a dictionary to be converted to xml. There should be only one key at 262 | the top level. The value must be a dict with (required) `children` key and 263 | (optional) `attrs` key. This will be called the `sub-element dictionary`. 264 | 265 | The `attrs` value must be a dictionary; each value will be added to the 266 | element's xml tag as attributes. e.g.: 267 | {"example": { 268 | "attrs": { 269 | "key1": "value1", 270 | ... 271 | }, 272 | ... 273 | }} 274 | 275 | would result in: 276 | 277 | 278 | If the value is a dict it must contain one or more keys which will be used 279 | as the sub-element names. Each sub-element must have a value of a sub-element 280 | dictionary(see above) or a list of sub-element dictionaries. 281 | If the value is not a dict, it will be the value of the element. 282 | If the value is a list, multiple elements of the same tag will be created 283 | from each sub-element dict in the list. 284 | 285 | :param data: dict: Used to create an XML tree. e.g.: 286 | example_data = { 287 | "toast": { 288 | "attrs": { 289 | "launch": "param", 290 | "duration": "short", 291 | }, 292 | "children": { 293 | "visual": { 294 | "children": { 295 | "binding": { 296 | "attrs": {"template": "ToastText01"}, 297 | "children": { 298 | "text": [ 299 | { 300 | "attrs": {"id": "1"}, 301 | "children": "text1", 302 | }, 303 | { 304 | "attrs": {"id": "2"}, 305 | "children": "text2", 306 | }, 307 | ], 308 | }, 309 | }, 310 | }, 311 | }, 312 | }, 313 | }, 314 | } 315 | :return: ElementTree.Element 316 | """ 317 | for key, value in data.items(): 318 | root = _add_element_attrs(ET.Element(key), value.get("attrs", {})) 319 | children = value.get("children", None) 320 | if isinstance(children, dict): 321 | _add_sub_elements_from_dict(root, children) 322 | return root 323 | 324 | 325 | def _add_sub_elements_from_dict(parent, sub_dict): 326 | """ 327 | Add SubElements to the parent element. 328 | 329 | :param parent: ElementTree.Element: The parent element for the newly created SubElement. 330 | :param sub_dict: dict: Used to create a new SubElement. See `dict_to_xml_schema` 331 | method docstring for more information. e.g.: 332 | {"example": { 333 | "attrs": { 334 | "key1": "value1", 335 | ... 336 | }, 337 | ... 338 | }} 339 | """ 340 | for key, value in sub_dict.items(): 341 | if isinstance(value, list): 342 | for repeated_element in value: 343 | sub_element = ET.SubElement(parent, key) 344 | _add_element_attrs(sub_element, repeated_element.get("attrs", {})) 345 | children = repeated_element.get("children", None) 346 | if isinstance(children, dict): 347 | _add_sub_elements_from_dict(sub_element, children) 348 | elif isinstance(children, str): 349 | sub_element.text = children 350 | else: 351 | sub_element = ET.SubElement(parent, key) 352 | _add_element_attrs(sub_element, value.get("attrs", {})) 353 | children = value.get("children", None) 354 | if isinstance(children, dict): 355 | _add_sub_elements_from_dict(sub_element, children) 356 | elif isinstance(children, str): 357 | sub_element.text = children 358 | 359 | 360 | def _add_element_attrs(elem, attrs): 361 | """ 362 | Add attributes to the given element. 363 | 364 | :param elem: ElementTree.Element: The element the attributes are being added to. 365 | :param attrs: dict: A dictionary of attributes. e.g.: 366 | {"attribute1": "value", "attribute2": "another"} 367 | :return: ElementTree.Element 368 | """ 369 | for attr, value in attrs.items(): 370 | elem.attrib[attr] = value 371 | return elem 372 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] 3 | 4 | [tool.pytest.ini_options] 5 | minversion = "6.0" 6 | addopts = "--cov push_notifications --cov-append --cov-branch --cov-report term-missing --cov-report=xml" 7 | 8 | [tool.ruff.format] 9 | indent-style = "tab" 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-push-notifications 3 | description = Send push notifications to mobile devices through GCM, APNS or WNS and to WebPush (Chrome, Firefox and Opera) in Django 4 | author = Jerome Leclanche 5 | author_email = jerome@leclan.ch 6 | url = https://github.com/jazzband/django-push-notifications 7 | download_url = https://github.com/jazzband/django-push-notifications/tarball/master 8 | classifiers = 9 | Development Status :: 5 - Production/Stable 10 | Environment :: Web Environment 11 | Framework :: Django 12 | Framework :: Django :: 2.2 13 | Framework :: Django :: 3.0 14 | Framework :: Django :: 3.1 15 | Framework :: Django :: 3.2 16 | Framework :: Django :: 4.0 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: MIT License 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3.7 22 | Programming Language :: Python :: 3.8 23 | Programming Language :: Python :: 3.9 24 | Programming Language :: Python :: 3.10 25 | Programming Language :: Python :: 3.11 26 | Topic :: Internet :: WWW/HTTP 27 | Topic :: System :: Networking 28 | 29 | [options] 30 | python_requires = >= 3.7 31 | packages = find: 32 | install_requires = 33 | Django>=2.2 34 | 35 | setup_requires = 36 | setuptools_scm 37 | 38 | [options.extras_require] 39 | APNS = 40 | apns2>=0.3.0 41 | importlib-metadata;python_version < "3.8" 42 | Django>=2.2 43 | 44 | WP = pywebpush>=1.3.0 45 | 46 | apns-async = aioapns>=3.1 47 | 48 | FCM = firebase-admin>=6.2 49 | APNS_ASYNC = aioapns>=3.1 50 | 51 | 52 | [options.packages.find] 53 | exclude = tests 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pathlib import Path 3 | from setuptools import setup 4 | 5 | 6 | this_directory = Path(__file__).parent 7 | long_description = (this_directory / "README.rst").read_text() 8 | setup( 9 | long_description=long_description, 10 | long_description_content_type='text/x-rst', 11 | use_scm_version={"version_scheme": "post-release"} 12 | ) 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-push-notifications/16d43c2f3b7593f288132731cc98cd3061bea54d/tests/__init__.py -------------------------------------------------------------------------------- /tests/responses.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from firebase_admin.messaging import BatchResponse, SendResponse 3 | 4 | 5 | FCM_SUCCESS = BatchResponse([SendResponse(resp={"name": "abc"}, exception=None)]) 6 | FCM_SUCCESS_MULTIPLE = BatchResponse([ 7 | SendResponse(resp={"name": "abc"}, exception=None), 8 | SendResponse(resp={"name": "abc2"}, exception=None) 9 | ], ) 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # assert warnings are enabled 2 | import warnings 3 | 4 | 5 | warnings.simplefilter("ignore", Warning) 6 | 7 | 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": "django.db.backends.sqlite3", 11 | } 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | "django.contrib.admin", 16 | "django.contrib.auth", 17 | "django.contrib.contenttypes", 18 | "django.contrib.sessions", 19 | "django.contrib.sites", 20 | "push_notifications", 21 | ] 22 | 23 | SITE_ID = 1 24 | ROOT_URLCONF = "core.urls" 25 | 26 | SECRET_KEY = "foobar" 27 | 28 | PUSH_NOTIFICATIONS_SETTINGS = { 29 | "WP_CLAIMS": {"sub": "mailto:jazzband@example.com"} 30 | } 31 | -------------------------------------------------------------------------------- /tests/settings_unique.py: -------------------------------------------------------------------------------- 1 | # assert warnings are enabled 2 | import warnings 3 | 4 | 5 | warnings.simplefilter("ignore", Warning) 6 | 7 | 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": "django.db.backends.sqlite3", 11 | } 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | "django.contrib.admin", 16 | "django.contrib.auth", 17 | "django.contrib.contenttypes", 18 | "django.contrib.sessions", 19 | "django.contrib.sites", 20 | "push_notifications", 21 | ] 22 | 23 | SITE_ID = 1 24 | ROOT_URLCONF = "core.urls" 25 | 26 | SECRET_KEY = "foobar" 27 | 28 | PUSH_NOTIFICATIONS_SETTINGS = { 29 | "WP_CLAIMS": {"sub": "mailto:jazzband@example.com"}, 30 | "UNIQUE_REG_ID": True 31 | } 32 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | 4 | 5 | from django.contrib.admin import AdminSite 6 | from django.contrib import messages 7 | from django.http import HttpRequest 8 | from django.test import TestCase 9 | 10 | from firebase_admin.messaging import Message, BatchResponse, SendResponse, UnregisteredError 11 | 12 | from push_notifications.admin import GCMDeviceAdmin 13 | from push_notifications.models import GCMDevice 14 | from tests import responses 15 | 16 | 17 | class GCMDeviceAdminTestCase(TestCase): 18 | def test_send_bulk_messages_action(self): 19 | request = HttpRequest() 20 | 21 | GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") 22 | queryset = GCMDevice.objects.all() 23 | admin = GCMDeviceAdmin(GCMDevice, AdminSite()) 24 | admin.message_user = mock.Mock() 25 | 26 | with mock.patch( 27 | "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS 28 | ) as p: 29 | admin.send_messages(request, queryset, bulk=True) 30 | 31 | # one call 32 | self.assertEqual(len(p.mock_calls), 1) 33 | 34 | call = p.call_args 35 | kwargs = call[1] 36 | 37 | self.assertTrue("dry_run" in kwargs) 38 | self.assertFalse(kwargs["dry_run"]) 39 | self.assertTrue("app" in kwargs) 40 | self.assertIsNone(kwargs["app"]) 41 | 42 | # only one message 43 | call_messages = call[0][0] 44 | self.assertEqual(len(call_messages), 1) 45 | 46 | message = call_messages[0] 47 | self.assertIsInstance(message, Message) 48 | self.assertEqual(message.token, "abc") 49 | self.assertEqual(message.android.notification.body, "Test bulk notification") 50 | 51 | admin.message_user.assert_called_once_with( 52 | request, "All messages were sent.", level=messages.SUCCESS 53 | ) 54 | 55 | def test_send_single_message_action(self): 56 | request = HttpRequest() 57 | 58 | GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") 59 | queryset = GCMDevice.objects.all() 60 | admin = GCMDeviceAdmin(GCMDevice, AdminSite()) 61 | admin.message_user = mock.Mock() 62 | 63 | with mock.patch( 64 | "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS 65 | ) as p: 66 | admin.send_messages(request, queryset, bulk=False) 67 | 68 | # one call 69 | self.assertEqual(len(p.mock_calls), 1) 70 | 71 | call = p.call_args 72 | kwargs = call[1] 73 | 74 | self.assertTrue("dry_run" in kwargs) 75 | self.assertFalse(kwargs["dry_run"]) 76 | self.assertTrue("app" in kwargs) 77 | self.assertIsNone(kwargs["app"]) 78 | 79 | # only one message 80 | call_messages = call[0][0] 81 | self.assertEqual(len(call_messages), 1) 82 | 83 | message = call_messages[0] 84 | self.assertIsInstance(message, Message) 85 | self.assertEqual(message.token, "abc") 86 | self.assertEqual(message.android.notification.body, "Test single notification") 87 | 88 | admin.message_user.assert_called_once_with( 89 | request, "All messages were sent.", level=messages.SUCCESS 90 | ) 91 | 92 | def test_send_bulk_messages_action_fail(self): 93 | request = HttpRequest() 94 | 95 | GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") 96 | queryset = GCMDevice.objects.all() 97 | admin = GCMDeviceAdmin(GCMDevice, AdminSite()) 98 | admin.message_user = mock.Mock() 99 | 100 | response = BatchResponse( 101 | [SendResponse(resp={"name": "..."}, exception=UnregisteredError("error"),)] 102 | ) 103 | 104 | with mock.patch( 105 | "firebase_admin.messaging.send_each", return_value=response 106 | ) as p: 107 | admin.send_messages(request, queryset, bulk=True) 108 | 109 | # one call 110 | self.assertEqual(len(p.mock_calls), 1) 111 | 112 | call = p.call_args 113 | kwargs = call[1] 114 | 115 | self.assertTrue("dry_run" in kwargs) 116 | self.assertFalse(kwargs["dry_run"]) 117 | self.assertTrue("app" in kwargs) 118 | self.assertIsNone(kwargs["app"]) 119 | 120 | # only one message 121 | call_messages = call[0][0] 122 | self.assertEqual(len(call_messages), 1) 123 | 124 | message = call_messages[0] 125 | self.assertIsInstance(message, Message) 126 | self.assertEqual(message.token, "abc") 127 | self.assertEqual(message.android.notification.body, "Test bulk notification") 128 | 129 | error_message = "Some messages could not be processed: UnregisteredError('error')" 130 | 131 | admin.message_user.assert_called_once_with( 132 | request, error_message, level=messages.ERROR 133 | ) 134 | -------------------------------------------------------------------------------- /tests/test_apns_async_models.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from unittest import mock 4 | 5 | import pytest 6 | from django.conf import settings 7 | from django.test import TestCase, override_settings 8 | 9 | 10 | try: 11 | from aioapns.common import NotificationResult 12 | 13 | from push_notifications.exceptions import APNSError 14 | from push_notifications.models import APNSDevice 15 | except ModuleNotFoundError: 16 | # skipping because apns2 is not supported on python 3.10 17 | # it uses hyper that imports from collections which were changed in 3.10 18 | # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" 19 | if sys.version_info < (3, 10): 20 | pytest.skip(allow_module_level=True) 21 | else: 22 | raise 23 | 24 | 25 | class APNSModelTestCase(TestCase): 26 | def _create_devices(self, devices): 27 | for device in devices: 28 | APNSDevice.objects.create(registration_id=device) 29 | 30 | @override_settings() 31 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 32 | def test_apns_send_bulk_message(self, mock_apns): 33 | self._create_devices(["abc", "def"]) 34 | 35 | # legacy conf manager requires a value 36 | settings.PUSH_NOTIFICATIONS_SETTINGS.update( 37 | {"APNS_CERTIFICATE": "/path/to/apns/certificate.pem"} 38 | ) 39 | 40 | APNSDevice.objects.all().send_message("Hello world", expiration=time.time() + 3) 41 | 42 | [call1, call2] = mock_apns.return_value.send_notification.call_args_list 43 | req1 = call1.args[0] 44 | req2 = call2.args[0] 45 | 46 | self.assertEqual(req1.device_token, "abc") 47 | self.assertEqual(req2.device_token, "def") 48 | self.assertEqual(req1.message["aps"]["alert"], "Hello world") 49 | self.assertEqual(req2.message["aps"]["alert"], "Hello world") 50 | self.assertAlmostEqual(req1.time_to_live, 3, places=-1) 51 | self.assertAlmostEqual(req2.time_to_live, 3, places=-1) 52 | 53 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 54 | def test_apns_send_message_extra(self, mock_apns): 55 | self._create_devices(["abc"]) 56 | APNSDevice.objects.get().send_message( 57 | "Hello world", expiration=time.time() + 2, priority=5, extra={"foo": "bar"} 58 | ) 59 | 60 | args, kargs = mock_apns.return_value.send_notification.call_args 61 | req = args[0] 62 | 63 | self.assertEqual(req.device_token, "abc") 64 | self.assertEqual(req.message["aps"]["alert"], "Hello world") 65 | self.assertEqual(req.message["foo"], "bar") 66 | self.assertEqual(req.priority, 5) 67 | self.assertAlmostEqual(req.time_to_live, 2, places=-1) 68 | 69 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 70 | def test_apns_send_message(self, mock_apns): 71 | self._create_devices(["abc"]) 72 | APNSDevice.objects.get().send_message("Hello world", expiration=time.time() + 1) 73 | 74 | args, kargs = mock_apns.return_value.send_notification.call_args 75 | req = args[0] 76 | 77 | self.assertEqual(req.device_token, "abc") 78 | self.assertEqual(req.message["aps"]["alert"], "Hello world") 79 | self.assertAlmostEqual(req.time_to_live, 1, places=-1) 80 | 81 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 82 | def test_apns_send_message_to_single_device_with_error(self, mock_apns): 83 | # these errors are device specific, device.active will be set false 84 | devices = ["abc"] 85 | self._create_devices(devices) 86 | 87 | mock_apns.return_value.send_notification.return_value = NotificationResult( 88 | status="400", 89 | notification_id="abc", 90 | description="PayloadTooLarge", 91 | ) 92 | device = APNSDevice.objects.get(registration_id="abc") 93 | with self.assertRaises(APNSError) as ae: 94 | device.send_message("Hello World!") 95 | self.assertTrue("PayloadTooLarge" in ae.exception.message) 96 | self.assertTrue(APNSDevice.objects.get(registration_id="abc").active) 97 | 98 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 99 | def test_apns_send_message_to_several_devices_with_error(self, mock_apns): 100 | # these errors are device specific, device.active will be set false 101 | devices = ["abc", "def", "ghi"] 102 | expected_exceptions_statuses = ["PayloadTooLarge", "DeviceTokenNotForTopic", "Unregistered"] 103 | self._create_devices(devices) 104 | 105 | mock_apns.return_value.send_notification.side_effect = [ 106 | NotificationResult( 107 | status="400", 108 | notification_id="abc", 109 | description="PayloadTooLarge", 110 | ), 111 | NotificationResult( 112 | status="400", 113 | notification_id="def", 114 | description="DeviceTokenNotForTopic", 115 | ), 116 | NotificationResult( 117 | status="400", 118 | notification_id="ghi", 119 | description="Unregistered", 120 | ), 121 | ] 122 | 123 | for idx, token in enumerate(devices): 124 | device = APNSDevice.objects.get(registration_id=token) 125 | with self.assertRaises(APNSError) as ae: 126 | device.send_message("Hello World!") 127 | self.assertTrue(expected_exceptions_statuses[idx] in ae.exception.message) 128 | 129 | if idx == 0: 130 | self.assertTrue(APNSDevice.objects.get(registration_id=token).active) 131 | else: 132 | self.assertFalse(APNSDevice.objects.get(registration_id=token).active) 133 | 134 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 135 | def test_apns_send_message_to_bulk_devices_with_error(self, mock_apns): 136 | # these errors are device specific, device.active will be set false 137 | devices = ["abc", "def", "ghi"] 138 | results = [ 139 | NotificationResult( 140 | status="400", 141 | notification_id="abc", 142 | description="PayloadTooLarge", 143 | ), 144 | NotificationResult( 145 | status="400", 146 | notification_id="def", 147 | description="DeviceTokenNotForTopic", 148 | ), 149 | NotificationResult( 150 | status="400", 151 | notification_id="ghi", 152 | description="Unregistered", 153 | ), 154 | ] 155 | self._create_devices(devices) 156 | 157 | mock_apns.return_value.send_notification.side_effect = results 158 | 159 | with self.assertRaises(APNSError): 160 | APNSDevice.objects.all().send_message("Hello World!") 161 | 162 | for idx, token in enumerate(devices): 163 | if idx == 0: 164 | self.assertTrue(APNSDevice.objects.get(registration_id=token).active) 165 | else: 166 | self.assertFalse(APNSDevice.objects.get(registration_id=token).active) 167 | 168 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 169 | def test_apns_send_messages_different_priority(self, mock_apns): 170 | self._create_devices(["abc", "def"]) 171 | device_1 = APNSDevice.objects.get(registration_id="abc") 172 | device_2 = APNSDevice.objects.get(registration_id="def") 173 | 174 | device_1.send_message( 175 | "Hello world 1", 176 | expiration=time.time() + 1, 177 | priority=5, 178 | collapse_id="1", 179 | ) 180 | args_1, _ = mock_apns.return_value.send_notification.call_args 181 | 182 | device_2.send_message("Hello world 2") 183 | args_2, _ = mock_apns.return_value.send_notification.call_args 184 | 185 | req = args_1[0] 186 | self.assertEqual(req.device_token, "abc") 187 | self.assertEqual(req.message["aps"]["alert"], "Hello world 1") 188 | self.assertAlmostEqual(req.time_to_live, 1, places=-1) 189 | self.assertEqual(req.priority, 5) 190 | self.assertEqual(req.collapse_key, "1") 191 | 192 | reg_2 = args_2[0] 193 | self.assertEqual(reg_2.device_token, "def") 194 | self.assertEqual(reg_2.message["aps"]["alert"], "Hello world 2") 195 | self.assertIsNone(reg_2.time_to_live, "No time to live should be specified") 196 | self.assertIsNone(reg_2.priority, "No priority should be specified") 197 | self.assertIsNone(reg_2.collapse_key, "No collapse key should be specified") 198 | -------------------------------------------------------------------------------- /tests/test_apns_async_push_payload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from unittest import mock 4 | 5 | import pytest 6 | from django.test import TestCase 7 | 8 | 9 | try: 10 | from aioapns.common import NotificationResult 11 | from push_notifications.apns_async import TokenCredentials, apns_send_message, CertificateCredentials 12 | except ModuleNotFoundError: 13 | # skipping because apns2 is not supported on python 3.10 14 | # it uses hyper that imports from collections which were changed in 3.10 15 | # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" 16 | if sys.version_info < (3, 10): 17 | pytest.skip(allow_module_level=True) 18 | else: 19 | raise 20 | 21 | 22 | class APNSAsyncPushPayloadTest(TestCase): 23 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 24 | def test_push_payload(self, mock_apns): 25 | apns_send_message( 26 | "123", 27 | "Hello world", 28 | creds=TokenCredentials( 29 | key="aaa", 30 | key_id="bbb", 31 | team_id="ccc", 32 | ), 33 | badge=1, 34 | sound="chime", 35 | extra={"custom_data": 12345}, 36 | expiration=int(time.time()) + 3, 37 | ) 38 | self.assertTrue(mock_apns.called) 39 | args, kwargs = mock_apns.return_value.send_notification.call_args 40 | req = args[0] 41 | self.assertEqual(req.device_token, "123") 42 | self.assertEqual(req.message["aps"]["alert"], "Hello world") 43 | self.assertEqual(req.message["aps"]["badge"], 1) 44 | self.assertEqual(req.message["aps"]["sound"], "chime") 45 | self.assertEqual(req.message["custom_data"], 12345) 46 | self.assertEqual(req.time_to_live, 3) 47 | 48 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 49 | def test_push_payload_with_thread_id(self, mock_apns): 50 | apns_send_message( 51 | "123", 52 | "Hello world", 53 | thread_id="565", 54 | sound="chime", 55 | extra={"custom_data": 12345}, 56 | expiration=int(time.time()) + 3, 57 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 58 | ) 59 | args, kwargs = mock_apns.return_value.send_notification.call_args 60 | req = args[0] 61 | 62 | self.assertEqual(req.device_token, "123") 63 | self.assertEqual(req.message["aps"]["alert"], "Hello world") 64 | self.assertEqual(req.message["aps"]["thread-id"], "565") 65 | self.assertEqual(req.message["aps"]["sound"], "chime") 66 | self.assertEqual(req.message["custom_data"], 12345) 67 | self.assertAlmostEqual(req.time_to_live, 3, places=-1) 68 | 69 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 70 | def test_push_payload_with_alert_dict(self, mock_apns): 71 | apns_send_message( 72 | "123", 73 | alert={"title": "t1", "body": "b1"}, 74 | sound="chime", 75 | extra={"custom_data": 12345}, 76 | expiration=int(time.time()) + 3, 77 | creds=TokenCredentials( 78 | key="aaa", 79 | key_id="bbb", 80 | team_id="ccc", 81 | ), 82 | ) 83 | 84 | args, kwargs = mock_apns.return_value.send_notification.call_args 85 | req = args[0] 86 | 87 | self.assertEqual(req.device_token, "123") 88 | self.assertEqual(req.message["aps"]["alert"]["body"], "b1") 89 | self.assertEqual(req.message["aps"]["alert"]["title"], "t1") 90 | self.assertEqual(req.message["aps"]["sound"], "chime") 91 | self.assertEqual(req.message["custom_data"], 12345) 92 | self.assertAlmostEqual(req.time_to_live, 3, places=-1) 93 | 94 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 95 | def test_localised_push_with_empty_body(self, mock_apns): 96 | apns_send_message( 97 | "123", 98 | None, 99 | loc_key="TEST_LOC_KEY", 100 | expiration=time.time() + 3, 101 | creds=TokenCredentials( 102 | key="aaa", 103 | key_id="bbb", 104 | team_id="ccc", 105 | ), 106 | ) 107 | 108 | args, _kwargs = mock_apns.return_value.send_notification.call_args 109 | req = args[0] 110 | 111 | self.assertEqual(req.device_token, "123") 112 | self.assertEqual(req.message["aps"]["alert"]["loc-key"], "TEST_LOC_KEY") 113 | self.assertAlmostEqual(req.time_to_live, 3, places=-1) 114 | 115 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 116 | def test_using_extra(self, mock_apns): 117 | apns_send_message( 118 | "123", 119 | "sample", 120 | extra={"foo": "bar"}, 121 | expiration=(time.time() + 30), 122 | priority=10, 123 | creds=TokenCredentials( 124 | key="aaa", 125 | key_id="bbb", 126 | team_id="ccc", 127 | ), 128 | ) 129 | 130 | args, _kwargs = mock_apns.return_value.send_notification.call_args 131 | req = args[0] 132 | 133 | self.assertEqual(req.device_token, "123") 134 | self.assertEqual(req.message["aps"]["alert"], "sample") 135 | self.assertEqual(req.message["foo"], "bar") 136 | self.assertEqual(req.priority, 10) 137 | self.assertAlmostEqual(req.time_to_live, 30, places=-1) 138 | 139 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 140 | def test_collapse_id(self, mock_apns): 141 | apns_send_message( 142 | "123", 143 | "sample", 144 | collapse_id="456789", 145 | creds=TokenCredentials( 146 | key="aaa", 147 | key_id="bbb", 148 | team_id="ccc", 149 | ), 150 | ) 151 | 152 | args, kwargs = mock_apns.return_value.send_notification.call_args 153 | req = args[0] 154 | 155 | self.assertEqual(req.device_token, "123") 156 | self.assertEqual(req.message["aps"]["alert"], "sample") 157 | self.assertEqual(req.collapse_key, "456789") 158 | 159 | @mock.patch("aioapns.client.APNsCertConnectionPool", autospec=True) 160 | def test_aioapns_err_func(self, mock_cert_pool): 161 | mock_cert_pool.return_value.send_notification = mock.AsyncMock() 162 | result = NotificationResult( 163 | "123", "400" 164 | ) 165 | mock_cert_pool.return_value.send_notification.return_value = result 166 | err_func = mock.AsyncMock() 167 | with pytest.raises(Exception): 168 | apns_send_message( 169 | "123", 170 | "sample", 171 | creds=CertificateCredentials( 172 | client_cert="dummy/path.pem", 173 | ), 174 | topic="default", 175 | err_func=err_func, 176 | ) 177 | mock_cert_pool.assert_called_once() 178 | mock_cert_pool.return_value.send_notification.assert_called_once() 179 | mock_cert_pool.return_value.send_notification.assert_awaited_once() 180 | err_func.assert_called_with( 181 | mock.ANY, result 182 | ) 183 | 184 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 185 | def test_push_payload_with_mutable_content(self, mock_apns): 186 | apns_send_message( 187 | "123", 188 | "Hello world", 189 | mutable_content=True, 190 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 191 | sound="chime", 192 | extra={"custom_data": 12345}, 193 | expiration=int(time.time()) + 3, 194 | ) 195 | 196 | args, kwargs = mock_apns.return_value.send_notification.call_args 197 | req = args[0] 198 | 199 | # Assertions 200 | self.assertTrue("mutable-content" in req.message["aps"]) 201 | self.assertEqual(req.message["aps"]["mutable-content"], 1) # APNs expects 1 for True 202 | 203 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 204 | def test_push_payload_with_category(self, mock_apns): 205 | apns_send_message( 206 | "123", 207 | "Hello world", 208 | category="MESSAGE_CATEGORY", 209 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 210 | sound="chime", 211 | extra={"custom_data": 12345}, 212 | expiration=int(time.time()) + 3, 213 | ) 214 | 215 | args, kwargs = mock_apns.return_value.send_notification.call_args 216 | req = args[0] 217 | 218 | # Assertions 219 | self.assertTrue("category" in req.message["aps"]) 220 | self.assertEqual(req.message["aps"]["category"], "MESSAGE_CATEGORY") # Verify correct category value 221 | 222 | # def test_bad_priority(self): 223 | # with mock.patch("apns2.credentials.init_context"): 224 | # with mock.patch("apns2.client.APNsClient.connect"): 225 | # with mock.patch("apns2.client.APNsClient.send_notification") as s: 226 | # self.assertRaises(APNSUnsupportedPriority, _apns_send, "123", 227 | # "_" * 2049, priority=24) 228 | # s.assert_has_calls([]) 229 | 230 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 231 | def test_push_payload_with_content_available_bool_true(self, mock_apns): 232 | apns_send_message( 233 | "123", 234 | "Hello world", 235 | content_available=True, 236 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 237 | extra={"custom_data": 12345}, 238 | expiration=int(time.time()) + 3, 239 | ) 240 | 241 | args, kwargs = mock_apns.return_value.send_notification.call_args 242 | req = args[0] 243 | 244 | assert "content-available" in req.message["aps"] 245 | assert req.message["aps"]["content-available"] == 1 246 | 247 | 248 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 249 | def test_push_payload_with_content_available_bool_false(self, mock_apns): 250 | apns_send_message( 251 | "123", 252 | "Hello world", 253 | content_available=False, 254 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 255 | extra={"custom_data": 12345}, 256 | expiration=int(time.time()) + 3, 257 | ) 258 | 259 | args, kwargs = mock_apns.return_value.send_notification.call_args 260 | req = args[0] 261 | 262 | assert "content-available" not in req.message["aps"] 263 | 264 | 265 | @mock.patch("push_notifications.apns_async.APNs", autospec=True) 266 | def test_push_payload_with_content_available_not_set(self, mock_apns): 267 | apns_send_message( 268 | "123", 269 | "Hello world", 270 | creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), 271 | extra={"custom_data": 12345}, 272 | expiration=int(time.time()) + 3, 273 | ) 274 | 275 | args, kwargs = mock_apns.return_value.send_notification.call_args 276 | req = args[0] 277 | 278 | assert "content-available" not in req.message["aps"] 279 | -------------------------------------------------------------------------------- /tests/test_apns_models.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | 7 | try: 8 | from apns2.client import NotificationPriority 9 | from apns2.errors import BadTopic, PayloadTooLarge, Unregistered 10 | from django.conf import settings 11 | from django.test import TestCase, override_settings 12 | 13 | from push_notifications.exceptions import APNSError 14 | from push_notifications.models import APNSDevice 15 | except (AttributeError, ModuleNotFoundError): 16 | # skipping because apns2 is not supported on python 3.10 17 | # it uses hyper that imports from collections which were changed in 3.10 18 | # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" 19 | if sys.version_info >= (3, 10): 20 | pytest.skip(allow_module_level=True) 21 | else: 22 | raise 23 | 24 | class APNSModelTestCase(TestCase): 25 | def _create_devices(self, devices): 26 | for device in devices: 27 | APNSDevice.objects.create(registration_id=device) 28 | 29 | @override_settings() 30 | def test_apns_send_bulk_message(self): 31 | self._create_devices(["abc", "def"]) 32 | 33 | # legacy conf manager requires a value 34 | settings.PUSH_NOTIFICATIONS_SETTINGS.update( 35 | {"APNS_CERTIFICATE": "/path/to/apns/certificate.pem"} 36 | ) 37 | 38 | with mock.patch("apns2.credentials.init_context"): 39 | with mock.patch("apns2.client.APNsClient.connect"): 40 | with mock.patch("apns2.client.APNsClient.send_notification_batch") as s: 41 | APNSDevice.objects.all().send_message("Hello world", expiration=1) 42 | args, kargs = s.call_args 43 | self.assertEqual(args[0][0].token, "abc") 44 | self.assertEqual(args[0][1].token, "def") 45 | self.assertEqual(args[0][0].payload.alert, "Hello world") 46 | self.assertEqual(args[0][1].payload.alert, "Hello world") 47 | self.assertEqual(kargs["expiration"], 1) 48 | 49 | def test_apns_send_message_extra(self): 50 | self._create_devices(["abc"]) 51 | 52 | with mock.patch("apns2.credentials.init_context"): 53 | with mock.patch("apns2.client.APNsClient.connect"): 54 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 55 | APNSDevice.objects.get().send_message( 56 | "Hello world", expiration=2, priority=5, extra={"foo": "bar"} 57 | ) 58 | args, kargs = s.call_args 59 | self.assertEqual(args[0], "abc") 60 | self.assertEqual(args[1].alert, "Hello world") 61 | self.assertEqual(args[1].custom, {"foo": "bar"}) 62 | self.assertEqual(kargs["priority"], NotificationPriority.Delayed) 63 | self.assertEqual(kargs["expiration"], 2) 64 | 65 | def test_apns_send_message(self): 66 | self._create_devices(["abc"]) 67 | 68 | with mock.patch("apns2.credentials.init_context"): 69 | with mock.patch("apns2.client.APNsClient.connect"): 70 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 71 | APNSDevice.objects.get().send_message("Hello world", expiration=1) 72 | args, kargs = s.call_args 73 | self.assertEqual(args[0], "abc") 74 | self.assertEqual(args[1].alert, "Hello world") 75 | self.assertEqual(kargs["expiration"], 1) 76 | 77 | def test_apns_send_message_to_single_device_with_error(self): 78 | # these errors are device specific, device.active will be set false 79 | devices = ["abc"] 80 | self._create_devices(devices) 81 | 82 | with mock.patch("push_notifications.apns._apns_send") as s: 83 | s.side_effect = Unregistered 84 | device = APNSDevice.objects.get(registration_id="abc") 85 | with self.assertRaises(APNSError) as ae: 86 | device.send_message("Hello World!") 87 | self.assertEqual(ae.exception.status, "Unregistered") 88 | self.assertFalse(APNSDevice.objects.get(registration_id="abc").active) 89 | 90 | def test_apns_send_message_to_several_devices_with_error(self): 91 | # these errors are device specific, device.active will be set false 92 | devices = ["abc", "def", "ghi"] 93 | expected_exceptions_statuses = ["PayloadTooLarge", "BadTopic", "Unregistered"] 94 | self._create_devices(devices) 95 | 96 | with mock.patch("push_notifications.apns._apns_send") as s: 97 | s.side_effect = [PayloadTooLarge, BadTopic, Unregistered] 98 | 99 | for idx, token in enumerate(devices): 100 | device = APNSDevice.objects.get(registration_id=token) 101 | with self.assertRaises(APNSError) as ae: 102 | device.send_message("Hello World!") 103 | self.assertEqual(ae.exception.status, expected_exceptions_statuses[idx]) 104 | 105 | if idx == 2: 106 | self.assertFalse( 107 | APNSDevice.objects.get(registration_id=token).active 108 | ) 109 | else: 110 | self.assertTrue( 111 | APNSDevice.objects.get(registration_id=token).active 112 | ) 113 | 114 | def test_apns_send_message_to_bulk_devices_with_error(self): 115 | # these errors are device specific, device.active will be set false 116 | devices = ["abc", "def", "ghi"] 117 | results = {"abc": "PayloadTooLarge", "def": "BadTopic", "ghi": "Unregistered"} 118 | self._create_devices(devices) 119 | 120 | with mock.patch("push_notifications.apns._apns_send") as s: 121 | s.return_value = results 122 | 123 | results = APNSDevice.objects.all().send_message("Hello World!") 124 | 125 | for idx, token in enumerate(devices): 126 | if idx == 2: 127 | self.assertFalse( 128 | APNSDevice.objects.get(registration_id=token).active 129 | ) 130 | else: 131 | self.assertTrue( 132 | APNSDevice.objects.get(registration_id=token).active 133 | ) 134 | 135 | def test_apns_send_message_to_duplicated_device_with_error(self): 136 | # these errors are device specific, device.active will be set false 137 | devices = ["abc", "abc"] 138 | self._create_devices(devices) 139 | 140 | with mock.patch("push_notifications.apns._apns_send") as s: 141 | s.side_effect = Unregistered 142 | device = APNSDevice.objects.filter(registration_id="abc").first() 143 | with self.assertRaises(APNSError) as ae: 144 | device.send_message("Hello World!") 145 | self.assertEqual(ae.exception.status, "Unregistered") 146 | for device in APNSDevice.objects.filter(registration_id="abc"): 147 | self.assertFalse(device.active) 148 | -------------------------------------------------------------------------------- /tests/test_apns_push_payload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | 4 | import pytest 5 | from django.test import TestCase 6 | 7 | try: 8 | from apns2.client import NotificationPriority 9 | from push_notifications.apns import _apns_send 10 | from push_notifications.exceptions import APNSUnsupportedPriority 11 | except (AttributeError, ModuleNotFoundError): 12 | # skipping because apns2 is not supported on python 3.10 13 | # it uses hyper that imports from collections which were changed in 3.10 14 | # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" 15 | if sys.version_info >= (3, 10): 16 | pytest.skip(allow_module_level=True) 17 | else: 18 | raise 19 | 20 | 21 | class APNSPushPayloadTest(TestCase): 22 | 23 | def test_push_payload(self): 24 | with mock.patch("apns2.credentials.init_context"): 25 | with mock.patch("apns2.client.APNsClient.connect"): 26 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 27 | _apns_send( 28 | "123", "Hello world", badge=1, sound="chime", 29 | extra={"custom_data": 12345}, expiration=3 30 | ) 31 | 32 | self.assertTrue(s.called) 33 | args, kargs = s.call_args 34 | self.assertEqual(args[0], "123") 35 | self.assertEqual(args[1].alert, "Hello world") 36 | self.assertEqual(args[1].badge, 1) 37 | self.assertEqual(args[1].sound, "chime") 38 | self.assertEqual(args[1].custom, {"custom_data": 12345}) 39 | self.assertEqual(kargs["expiration"], 3) 40 | 41 | def test_push_payload_with_thread_id(self): 42 | with mock.patch("apns2.credentials.init_context"): 43 | with mock.patch("apns2.client.APNsClient.connect"): 44 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 45 | _apns_send( 46 | "123", "Hello world", thread_id="565", sound="chime", 47 | extra={"custom_data": 12345}, expiration=3 48 | ) 49 | args, kargs = s.call_args 50 | self.assertEqual(args[0], "123") 51 | self.assertEqual(args[1].alert, "Hello world") 52 | self.assertEqual(args[1].thread_id, "565") 53 | self.assertEqual(args[1].sound, "chime") 54 | self.assertEqual(args[1].custom, {"custom_data": 12345}) 55 | self.assertEqual(kargs["expiration"], 3) 56 | 57 | def test_push_payload_with_alert_dict(self): 58 | with mock.patch("apns2.credentials.init_context"): 59 | with mock.patch("apns2.client.APNsClient.connect"): 60 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 61 | _apns_send( 62 | "123", alert={"title": "t1", "body": "b1"}, sound="chime", 63 | extra={"custom_data": 12345}, expiration=3 64 | ) 65 | args, kargs = s.call_args 66 | self.assertEqual(args[0], "123") 67 | self.assertEqual(args[1].alert["body"], "b1") 68 | self.assertEqual(args[1].alert["title"], "t1") 69 | self.assertEqual(args[1].sound, "chime") 70 | self.assertEqual(args[1].custom, {"custom_data": 12345}) 71 | self.assertEqual(kargs["expiration"], 3) 72 | 73 | def test_localised_push_with_empty_body(self): 74 | with mock.patch("apns2.credentials.init_context"): 75 | with mock.patch("apns2.client.APNsClient.connect"): 76 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 77 | _apns_send("123", None, loc_key="TEST_LOC_KEY", expiration=3) 78 | args, kargs = s.call_args 79 | self.assertEqual(args[0], "123") 80 | self.assertEqual(args[1].alert.body_localized_key, "TEST_LOC_KEY") 81 | self.assertEqual(kargs["expiration"], 3) 82 | 83 | def test_using_extra(self): 84 | with mock.patch("apns2.credentials.init_context"): 85 | with mock.patch("apns2.client.APNsClient.connect"): 86 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 87 | _apns_send( 88 | "123", "sample", extra={"foo": "bar"}, 89 | expiration=30, priority=10 90 | ) 91 | args, kargs = s.call_args 92 | self.assertEqual(args[0], "123") 93 | self.assertEqual(args[1].alert, "sample") 94 | self.assertEqual(args[1].custom, {"foo": "bar"}) 95 | self.assertEqual(kargs["priority"], NotificationPriority.Immediate) 96 | self.assertEqual(kargs["expiration"], 30) 97 | 98 | def test_collapse_id(self): 99 | with mock.patch("apns2.credentials.init_context"): 100 | with mock.patch("apns2.client.APNsClient.connect"): 101 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 102 | _apns_send( 103 | "123", "sample", collapse_id="456789" 104 | ) 105 | args, kargs = s.call_args 106 | self.assertEqual(args[0], "123") 107 | self.assertEqual(args[1].alert, "sample") 108 | self.assertEqual(kargs["collapse_id"], "456789") 109 | 110 | def test_bad_priority(self): 111 | with mock.patch("apns2.credentials.init_context"): 112 | with mock.patch("apns2.client.APNsClient.connect"): 113 | with mock.patch("apns2.client.APNsClient.send_notification") as s: 114 | self.assertRaises(APNSUnsupportedPriority, _apns_send, "123", "_" * 2049, priority=24) 115 | s.assert_has_calls([]) 116 | -------------------------------------------------------------------------------- /tests/test_app_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.test import TestCase 5 | 6 | from push_notifications.conf import AppConfig 7 | 8 | 9 | class AppConfigTestCase(TestCase): 10 | def test_application_id_required(self): 11 | """Using AppConfig without an application_id raises ImproperlyConfigured.""" 12 | 13 | manager = AppConfig() 14 | with self.assertRaises(ImproperlyConfigured): 15 | manager._get_application_settings(None, None, None) 16 | 17 | def test_application_not_found(self): 18 | """ 19 | Using AppConfig with an application_id that does not exist raises 20 | ImproperlyConfigured. 21 | """ 22 | 23 | application_id = "my_fcm_app" 24 | 25 | manager = AppConfig() 26 | 27 | with self.assertRaises(ImproperlyConfigured): 28 | manager._get_application_settings(application_id, "FCM", "API_KEY") 29 | 30 | def test_platform_configured(self): 31 | """ 32 | Using AppConfig with an application config that does not define PLATFORM 33 | raises ImproperlyConfigured. 34 | """ 35 | 36 | application_id = "my_fcm_app" 37 | PUSH_SETTINGS = { 38 | "APPLICATIONS": { 39 | application_id: {} 40 | } 41 | } 42 | 43 | with self.assertRaises(ImproperlyConfigured): 44 | AppConfig(PUSH_SETTINGS) 45 | 46 | def test_platform_invalid(self): 47 | """ 48 | Using AppConfig with an invalid platform raises ImproperlyConfigured. 49 | """ 50 | 51 | application_id = "my_fcm_app" 52 | PUSH_SETTINGS = { 53 | "APPLICATIONS": { 54 | application_id: { 55 | "PLATFORM": "XXX" 56 | } 57 | } 58 | } 59 | 60 | with self.assertRaises(ImproperlyConfigured): 61 | AppConfig(PUSH_SETTINGS) 62 | 63 | def test_platform_invalid_setting(self): 64 | """ 65 | Fetching application settings for the wrong platform raises ImproperlyConfigured. 66 | """ 67 | 68 | application_id = "my_fcm_app" 69 | PUSH_SETTINGS = { 70 | "APPLICATIONS": { 71 | application_id: { 72 | "PLATFORM": "FCM", 73 | } 74 | } 75 | } 76 | 77 | manager = AppConfig(PUSH_SETTINGS) 78 | 79 | with self.assertRaises(ImproperlyConfigured): 80 | manager._get_application_settings(application_id, "APNS", "CERTIFICATE") 81 | 82 | def test_validate_apns_config(self): 83 | """ 84 | Verify the settings for APNS platform. 85 | """ 86 | 87 | path = os.path.join(os.path.dirname(__file__), "test_data", "good_revoked.pem") 88 | 89 | # 90 | # all settings specified, required and optional, does not raise an error. 91 | # 92 | PUSH_SETTINGS = { 93 | "APPLICATIONS": { 94 | "my_apns_app": { 95 | "PLATFORM": "APNS", 96 | "CERTIFICATE": path, 97 | "USE_ALTERNATIVE_PORT": True, 98 | "USE_SANDBOX": True 99 | } 100 | } 101 | } 102 | AppConfig(PUSH_SETTINGS) 103 | 104 | # missing required settings 105 | PUSH_SETTINGS = { 106 | "APPLICATIONS": { 107 | "my_apns_app": { 108 | "PLATFORM": "APNS", 109 | } 110 | } 111 | } 112 | 113 | with self.assertRaises(ImproperlyConfigured) as ic: 114 | AppConfig(PUSH_SETTINGS) 115 | 116 | self.assertEqual( 117 | str(ic.exception), 118 | ("PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS[\"my_apns_app\"][\"('CERTIFICATE', ['AUTH_KEY_PATH', 'AUTH_KEY_ID', 'TEAM_ID'])\"] is missing.") 119 | ) 120 | 121 | # 122 | # certificate settings, with optional settings having default values 123 | # 124 | PUSH_SETTINGS = { 125 | "APPLICATIONS": { 126 | "my_apns_app": { 127 | "PLATFORM": "APNS", 128 | "CERTIFICATE": path, 129 | } 130 | } 131 | } 132 | 133 | manager = AppConfig(PUSH_SETTINGS) 134 | app_config = manager._settings["APPLICATIONS"]["my_apns_app"] 135 | 136 | assert app_config["USE_SANDBOX"] is False 137 | assert app_config["USE_ALTERNATIVE_PORT"] is False 138 | 139 | # certificate settings, with optional settings having default values 140 | # 141 | PUSH_SETTINGS = { 142 | "APPLICATIONS": { 143 | "my_apns_app": { 144 | "PLATFORM": "APNS", 145 | "AUTH_KEY_PATH": path, 146 | "AUTH_KEY_ID": "123456", 147 | "TEAM_ID": "123456", 148 | } 149 | } 150 | } 151 | 152 | manager = AppConfig(PUSH_SETTINGS) 153 | app_config = manager._settings["APPLICATIONS"]["my_apns_app"] 154 | 155 | assert app_config["USE_SANDBOX"] is False 156 | assert app_config["USE_ALTERNATIVE_PORT"] is False 157 | 158 | def test_get_allowed_settings_fcm(self): 159 | """Verify the settings allowed for FCM platform.""" 160 | 161 | # 162 | # all settings specified, required and optional, does not raise an error. 163 | # 164 | PUSH_SETTINGS = { 165 | "APPLICATIONS": { 166 | "my_fcm_app": { 167 | "PLATFORM": "FCM", 168 | "MAX_RECIPIENTS": "...", 169 | "FIREBASE_APP": "...", 170 | } 171 | } 172 | } 173 | AppConfig(PUSH_SETTINGS) 174 | 175 | # old API_KEY does not work anymore 176 | PUSH_SETTINGS = { 177 | "APPLICATIONS": { 178 | "my_fcm_app": { 179 | "PLATFORM": "FCM", 180 | "API_KEY": "...", 181 | } 182 | } 183 | } 184 | 185 | with self.assertRaises(ImproperlyConfigured): 186 | AppConfig(PUSH_SETTINGS) 187 | 188 | # all optional settings have default values 189 | PUSH_SETTINGS = { 190 | "APPLICATIONS": { 191 | "my_fcm_app": { 192 | "PLATFORM": "FCM", 193 | } 194 | } 195 | } 196 | 197 | manager = AppConfig(PUSH_SETTINGS) 198 | app_config = manager._settings["APPLICATIONS"]["my_fcm_app"] 199 | 200 | assert app_config["MAX_RECIPIENTS"] == 1000 201 | assert app_config["FIREBASE_APP"] is None 202 | 203 | def test_get_allowed_settings_wns(self): 204 | """ 205 | Verify the settings allowed for WNS platform. 206 | """ 207 | 208 | # all settings specified, required and optional, does not raise an error. 209 | PUSH_SETTINGS = { 210 | "APPLICATIONS": { 211 | "my_wns_app": { 212 | "PLATFORM": "WNS", 213 | "PACKAGE_SECURITY_ID": "...", 214 | "SECRET_KEY": "...", 215 | "WNS_ACCESS_URL": "...", 216 | } 217 | } 218 | } 219 | AppConfig(PUSH_SETTINGS) 220 | 221 | # missing required settings 222 | PUSH_SETTINGS = { 223 | "APPLICATIONS": { 224 | "my_wns_app": { 225 | "PLATFORM": "WNS", 226 | } 227 | } 228 | } 229 | 230 | with self.assertRaises(ImproperlyConfigured): 231 | AppConfig(PUSH_SETTINGS) 232 | 233 | # all optional settings have default values 234 | PUSH_SETTINGS = { 235 | "APPLICATIONS": { 236 | "my_wns_app": { 237 | "PLATFORM": "WNS", 238 | "PACKAGE_SECURITY_ID": "...", 239 | "SECRET_KEY": "...", 240 | } 241 | } 242 | } 243 | 244 | manager = AppConfig(PUSH_SETTINGS) 245 | app_config = manager._settings["APPLICATIONS"]["my_wns_app"] 246 | 247 | assert app_config["WNS_ACCESS_URL"] == "https://login.live.com/accesstoken.srf" 248 | -------------------------------------------------------------------------------- /tests/test_data/good_revoked.pem: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | friendlyName: Apple Development IOS Push Services: com.baseride.Magnitapp 3 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 4 | subject=/UID=com.baseride.Magnitapp/CN=Apple Development IOS Push Services: com.baseride.Magnitapp/OU=QAMD48Y2CA/C=US 5 | issuer=/C=US/O=Apple Inc./OU=Apple Worldwide Developer Relations/CN=Apple Worldwide Developer Relations Certification Authority 6 | -----BEGIN CERTIFICATE----- 7 | MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 8 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 9 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 10 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 11 | HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk 12 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 13 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 14 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 15 | AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp 16 | yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV 17 | 72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg 18 | /hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif 19 | u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0 20 | EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh 21 | MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud 22 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 23 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 24 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 25 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 26 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 27 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 28 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 29 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 30 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 31 | ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7 32 | Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE 33 | 6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG 34 | hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO 35 | 0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe 36 | 7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A== 37 | -----END CERTIFICATE----- 38 | Bag Attributes 39 | friendlyName: PushNotificationCloudBus 40 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 41 | Key Attributes: 42 | -----BEGIN RSA PRIVATE KEY----- 43 | MIIEogIBAAKCAQEA0W06MJky6FWGgQ2JHV3zzGwF4oHYPFOFwCEKe2nJhIZ5DKqz 44 | MyCmQzgNYasX8GYDPLAC+JL1ji7JnpZprBLRWKpZ1EbiUvWuI4qAJNXvYfjyWoov 45 | DJG5BBNcI5IGxCBeHHFa4NzycxobkuCkk6qMcz5btPOwzvYrMNqB02D+FSp/Xq5d 46 | up18JdxHIv33Bs+wBDVOsjfATFMCakQGl6jvjYiuG8zr8ClB4qUeiJ+7j2aC5NzI 47 | fiwUs835PbOa7ZpLauyBmvKPUzOr/IoTyriXTo7bP8SVURywIU9phXQQXuc0Qbiz 48 | DWSJQMR7sdMEUWmhGLVr2wkujJOEVekkzBsgnwIDAQABAoIBACOs06jLsBxb1VnO 49 | kHjsNEeybx4yuD8uiy47cqmrT6S/s4cw3O3steXleoIUvzM4bXy9DwSBJEtgNQBK 50 | 5x1k5zyPaFX87TjsmQl84m9j8i9iVQaPW4xslnPXSG7WxUhLqzx1IuIDQVnSLLhM 51 | hDyTZPGMwdqFWK0oyhq8Xjk/4IiCMcYG2M14jGVvEIsjMF246v+inAIpSUwZr1FD 52 | qzylj1FRnm0hTjXKIWrvumDiIodybFK5ruGbaKWlciokmyBaFXlt5JCzG1hrGetf 53 | wgg6gomjqSf7WuWILjWhHr6ZeNVKm8KdyOCs0csY1DSQj+CsLjUCF8fvE+59dN2k 54 | /u+qASECgYEA9Me6OcT6EhrbD1aqmDfg+DgFp6IOkP0We8Y2Z3Yg9fSzwRz0bZmE 55 | T9juuelhUxDph74fHvLmE0iMrueMIbWvzF8xGef0JIpvMVQmxvslzqRLFfPRclbA 56 | WoSWm8pzaI/X+tZetlQySoVVeS21HbzIEKnPdFBdkyC397xyV+iCQLsCgYEA2wao 57 | llTQ9TvQYDKad6T8RsgdCnk/BwLaq6EkLI+sNOBDRlzeSYbKkfqkwIPOhORe/ibg 58 | 2OO76P8QrmqLg76hQlYK4i6k/Fwz3pRajdfQ6KxS7sOLm0x5aqrFXHVhKVnCD5C9 59 | PldJ2mOAowAEe7HMPcNeYbX9bW6T1hcslTKkI20CgYAJxkP4dJYrzOi8dxB+3ZRd 60 | NRd8tyrvvTt9m8+mWAA+8hOPfZGBIuU2rwnxYJFjWMSKiBwEB10KnhYIEfT1j6TC 61 | e3ahezKzlteT17FotrSuyL661K6jazVpJ+w/sljjbwMH4DGOBFSxxxs/qISX+Gbg 62 | y3ceROtHqcHO4baLLhytawKBgC9wosVk+5mSahDcBQ8TIj1mjLu/BULMgHaaQY6R 63 | U/hj9s5fwRnl4yx5QIQeSHYKTPT5kMwJj6Lo1EEi/LL9cEpA/rx84+lxQx7bvT1p 64 | 2Gr9ID1tB2kMyGOtN3BOUEw3j8v1SrgdCfcOhEdJ8q6kFRvvnBrH42t3fvfpLxPl 65 | 0x2FAoGAbSkII3zPpc8mRcD3rtyOl2TlahBtXMuxfWSxsg/Zwf47crBfEbLD+5wz 66 | 7A9qnfwiDO98GJyE5/s+ynnL2PhIomCm0P13nkZuC4d9twYMtEvcD20mdQ+gsEhz 67 | Eg8ssRvYkO8DQwAFJKJVwVtVqMcnm/fkWu8GIfgqH6/fWNev6vs= 68 | -----END RSA PRIVATE KEY----- 69 | -------------------------------------------------------------------------------- /tests/test_data/good_with_passwd.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFkTCCBHmgAwIBAgIIRc+fhlv8zowwDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 3 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 4 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 5 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 6 | HhcNMTUxMjE1MTIzMDE3WhcNMTYxMjE0MTIzMDE3WjCBkDEmMCQGCgmSJomT8ixk 7 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 8 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 9 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 10 | AQEBBQADggEPADCCAQoCggEBAJq6041XOdS4wTOT6UeWVKr6DqZFsYSTA8TFVyqT 11 | cZYc19KWi9gQ2NK+WwsoRxHMmtAdZxYMTecMlqD/B4r3aiNpMjZWV8x25ymjwlGa 12 | 2zLZJ6y05/j2YDAk5mNSCensQmKOB4aJ0MtCnCbONDY1GDlB1PXMqs9VsWkI+glC 13 | T4DF0PdF6cWqeR1SRm0vm32WHBX4RkMJp4QxE2jYDS0ENWTnkqOQ0JLLk2eb/2Lq 14 | Tk0/F7wemyOsmYpscSnuwtYM0zkl2un5eWQR0pzpBStvVQP7TWyQPmEnasIGccWK 15 | LBftpJTCvG9eJkJhyH9UtoKMFq7r58WfggdLb/mL9ZAf+7cCAwEAAaOCAeUwggHh 16 | MB0GA1UdDgQWBBTGL6K5Ta3vxjOLdQTBY/wDTMYpbTAJBgNVHRMEAjAAMB8GA1Ud 17 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 18 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 19 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 20 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 21 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 22 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 23 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 24 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 25 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 26 | ADANBgkqhkiG9w0BAQUFAAOCAQEAayrzBuIGSZnIMbF+DhAlWeJNNimNSFOWX7X8 27 | f3YKytp8F5LpvX1kvDuJwntyXgdBsziQYtMG/RweQ9DZSYnCEzWDCQFckn9F67Eb 28 | fj1ohidh+sPgfsuV8sx5Rm9wvCdg1jSSrqxnHMDxuReX+d8DjU9e2Z1fqd7wcEbk 29 | LJUWxBR7+0KGYOqqUa5OrGS7tYcZj0v7f3xJyqsYVFSfYk1h7WoTr11bCSdCV+0y 30 | zzeVLQB4RQLQt+LLb1OZlj5NdM73fSicTwy3ihFxvWPTDozxfCBvIgLT3PYJBc3k 31 | NonhJADFD84YUTCnZC/xreAbjY7ss90fKLzxJfzMB+Wskf/EKQ== 32 | -----END CERTIFICATE----- 33 | Bag Attributes 34 | friendlyName: CloudTrack Push 35 | localKeyID: C6 2F A2 B9 4D AD EF C6 33 8B 75 04 C1 63 FC 03 4C C6 29 6D 36 | Key Attributes: 37 | -----BEGIN RSA PRIVATE KEY----- 38 | Proc-Type: 4,ENCRYPTED 39 | DEK-Info: DES-EDE3-CBC,61A87CB1762663CC 40 | 41 | z8z9Q9eVhRazYHnvx1LOJtWp9v7UaX/YluJ8qFuH8QG1cRbn5wxYqz61ZECDNQIl 42 | bUYRW95QQ1GO8PpNzJ+0z0tyJ63TuzZvg1GlhGtDSCpQaRfS1SGypIYsejnEwsUN 43 | IppR63g4TJALeP80KFGetIGhvpUURiAhRV+HV44naMkUfExa12YJs6b7ZLRN4uDz 44 | JWl41t+h/nvXKgNtIyVQIMj6rTkrLNE0YQ8fgerc8L7XSYOg0mdpp3CyLn9rkrWI 45 | rCbxjHyudT+LzJZBr0KWJZ2FvJp3KGVGAtGhUJ3biuRqKw6syUlCSDEaRmYSiI4C 46 | GvINoBMag7nXS6lbsEgpS8+N43tT13uxmzNDax/5ASMXuslFaD/s2GbcUXxWv2YL 47 | +GybNO83C8TzUDavWEzUBcbWdboim+Rh2HELPFNt1fEUzyj7ekqboT5YTQ4ceLJY 48 | dgjM9kNCKYum8Gfy5gfXPSwIGOKPo6hssHMEOVjDLM3169POfRc11KWIU4NEGZP8 49 | 4CML5mrYdP/y3KziPDyXRUvlwGNJh9mr7ucqyjfLd5fBrYZit2jE2zlD8H8UQf1F 50 | 0VMmw6Szc6pimxhLOqXu1jHfCvP9s9w5dY8s2MFKS5trevsXNI5RzDuF2cBlGk0l 51 | 3x3akNkq10jiqsN/v+BWpmhEMhf46/BqLDGIXBsDGkqVpjunJ9Hn+lc4Fkwtq73d 52 | LSLcPit3WRifgd9NX5BJKoSEalyCHnsteWFteS+W3J1lEnH1E8Vri6V5aOefwcFI 53 | lDn34XTB/huzi31p6301vhGftI4+qcYVm322TcSvyMR4jwZ28UCMrgFa+RH4Xn4n 54 | W0OmbaDjDzwvXkh9RlgTyuLQNR64ZVb27kYsUGumoNmg7DbpmM6PCaTTKyMw6Tgo 55 | CvGZ/cdpGOcgwFVM40HaIFsH12QIUeAkepHWuzkvhUlAv9mAOZtgV8q5er0kPPBs 56 | AgTIqCWJtlkU54HGdToR59TlENKAVxO2+v98T8I28NvduMYiwP94Ihfd/pto1tAg 57 | Ovwb3HudRNuGO/IrQokTY9B7yyldZ9YIC5suJwQ+1M06HK3D4E81GsfifQvUoZjD 58 | 4foEf+gEdHt3ayUk98oHw9k/LNKkZhRviBHvFR7NnCTY77EX4LfHR0E3h2DzWIU+ 59 | oGC8InbtN9eV8o05SiRusM3zGK9qn3nHmw/KjjO6K+FxwnoaKHYYOyL1Xdu/CJGR 60 | 0+vLKqIUTOoVK0Ox3yj2zaJWpm5rgKdTxhCoopS4LoHP+J7h24LwxcCk/rVTd6o5 61 | YIX5MyyW7e17uB96KYPwFioCSpFQKECd929r71Mm6uitf34/FIUBoglJozuOKXDf 62 | dnzMVRqLNA8qdX1+sN5XQeyBjFp2eokiampycIyo07buU6khEemZxvGOfQsbpHD6 63 | AuBk4Dj/3oYqlXWmg2aGiUHsERbHnwcHGNP1QBMFnZtZTGYiK4dc5JS90drDCIXI 64 | c1XpnJ6f5yqQZS8eUMO6x+cUxYRqCvsPyZPTP07J3zem4i/os20S5tJXH4PctzuX 65 | YQ5JiUOVkPFG77gw1Cq/WKLppS3k7+VRcNbX9wWZb6fs/Ruo1STtPG4llFNC8DcG 66 | -----END RSA PRIVATE KEY----- 67 | -------------------------------------------------------------------------------- /tests/test_data/without_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 3 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 4 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 5 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 6 | HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk 7 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 8 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 9 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 10 | AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp 11 | yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV 12 | 72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg 13 | /hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif 14 | u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0 15 | EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh 16 | MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud 17 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 18 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 19 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 20 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 21 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 22 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 23 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 24 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 25 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 26 | ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7 27 | Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE 28 | 6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG 29 | hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO 30 | 0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe 31 | 7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A== 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /tests/test_dict_to_message.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from firebase_admin.messaging import Message 3 | 4 | from push_notifications.gcm import dict_to_fcm_message 5 | 6 | 7 | class DictToMessageTest(TestCase): 8 | 9 | def test_dry_run_none(self): 10 | message_no = dict_to_fcm_message({"message": "Hello World", "dry_run": True}) 11 | message_no_kwargs = dict_to_fcm_message({"message": "Hello World"}, dry_run=True) 12 | message_yes = dict_to_fcm_message({"message": "Hello World", "dry_run": False}) 13 | 14 | assert message_no is None 15 | assert message_no_kwargs is None 16 | assert isinstance(message_yes, Message) 17 | assert message_yes.android.notification.body == "Hello World" 18 | 19 | def test_kwargs(self): 20 | message = dict_to_fcm_message( 21 | {}, 22 | time_to_live=3600, 23 | collapse_key="collapse key", 24 | priority="high", 25 | restricted_package_name="restricted.package.name" 26 | ) 27 | 28 | assert message.android.ttl == 3600 29 | assert message.android.collapse_key == "collapse key" 30 | assert message.android.priority == "high" 31 | assert message.android.restricted_package_name == "restricted.package.name" 32 | 33 | def test_payload_keys(self): 34 | """ 35 | old FCM_NOTIFICATIONS_PAYLOAD_KEYS payload is mapped to message object correctly 36 | """ 37 | payload = { 38 | "message": "Hello World", 39 | "title": "Title", 40 | "body": "Body", 41 | "icon": "Icon", 42 | "image": "Image", 43 | "sound": "Sound", 44 | "badge": "10", 45 | "color": "Color", 46 | "tag": "Tag", 47 | "click_action": "Click Action", 48 | "body_loc_key": "Body Loc Key", 49 | "body_loc_args": "Body Loc Args", 50 | "title_loc_key": "Title Loc Key", 51 | "title_loc_args": "Title Loc Args", 52 | "android_channel_id": "Android Channel Id", 53 | } 54 | 55 | message = dict_to_fcm_message(payload) 56 | 57 | assert message.android.notification.title == "Title" 58 | assert message.android.notification.body == "Body" 59 | assert message.android.notification.icon == "Icon" 60 | assert message.android.notification.image == "Image" 61 | assert message.android.notification.sound == "Sound" 62 | assert message.android.notification.notification_count == "10" 63 | assert message.android.notification.color == "Color" 64 | assert message.android.notification.tag == "Tag" 65 | assert message.android.notification.click_action == "Click Action" 66 | assert message.android.notification.body_loc_key == "Body Loc Key" 67 | assert message.android.notification.body_loc_args == "Body Loc Args" 68 | assert message.android.notification.title_loc_key == "Title Loc Key" 69 | assert message.android.notification.title_loc_args == "Title Loc Args" 70 | assert message.android.notification.channel_id == "Android Channel Id" 71 | 72 | def test_fcm_options(self): 73 | """ 74 | old FCM_OPTIONS_KEYS payload is mapped to message object correctly 75 | """ 76 | 77 | payload = { 78 | "message": "Hello World", 79 | "collapse_key": "Collapse Key", 80 | "priority": "High", 81 | "time_to_live": 3600, 82 | "restricted_package_name": "restricted.package.name", 83 | } 84 | 85 | message = dict_to_fcm_message(payload) 86 | 87 | assert message.android.collapse_key == "Collapse Key" 88 | assert message.android.priority == "High" 89 | assert message.android.ttl == 3600 90 | assert message.android.restricted_package_name == "restricted.package.name" 91 | 92 | def test_receiver_mapping_topic(self): 93 | payload = { 94 | "message": "Hello World", 95 | "to": "/topic/...", 96 | } 97 | 98 | message = dict_to_fcm_message(payload) 99 | assert message.topic == "/topic/..." 100 | assert message.token is None 101 | 102 | def test_receiver_mapping_token(self): 103 | payload = { 104 | "message": "Hello World", 105 | "to": "...", 106 | } 107 | 108 | message = dict_to_fcm_message(payload) 109 | assert message.topic is None 110 | assert message.token == "..." 111 | 112 | def test_receiver_mapping_condition(self): 113 | payload = { 114 | "message": "Hello World", 115 | "condition": "...", 116 | } 117 | 118 | message = dict_to_fcm_message(payload) 119 | assert message.condition == "..." 120 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import SimpleTestCase 3 | 4 | from push_notifications.fields import HexadecimalField 5 | 6 | 7 | class HexadecimalFieldTestCase(SimpleTestCase): 8 | _INVALID_HEX_VALUES = [ 9 | "foobar", 10 | "GLUTEN", 11 | "HeLLo WoRLd", 12 | "international", 13 | "°!#€%&/()[]{}=?", 14 | "0x", 15 | ] 16 | 17 | _VALID_HEX_VALUES = { 18 | "babe": "babe", 19 | "BEEF": "BEEF", 20 | " \nfeed \t": "feed", 21 | "0x012345789abcdef": "0x012345789abcdef", 22 | "012345789aBcDeF": "012345789aBcDeF", 23 | } 24 | 25 | def test_clean_invalid_values(self): 26 | """Passing invalid values raises ValidationError.""" 27 | f = HexadecimalField() 28 | for invalid in self._INVALID_HEX_VALUES: 29 | self.assertRaisesMessage( 30 | ValidationError, 31 | "'Enter a valid hexadecimal number'", 32 | f.clean, 33 | invalid, 34 | ) 35 | 36 | def test_clean_valid_values(self): 37 | """Passing valid values returns the expected output.""" 38 | f = HexadecimalField() 39 | for valid, expected in self._VALID_HEX_VALUES.items(): 40 | self.assertEqual(expected, f.clean(valid)) 41 | -------------------------------------------------------------------------------- /tests/test_gcm_push_payload.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.test import TestCase 5 | from firebase_admin.messaging import Message 6 | 7 | from push_notifications.gcm import dict_to_fcm_message, send_message 8 | 9 | from .responses import FCM_SUCCESS 10 | 11 | 12 | class GCMPushPayloadTest(TestCase): 13 | 14 | def test_fcm_push_payload(self): 15 | with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: 16 | message = dict_to_fcm_message({"message": "Hello world"}) 17 | 18 | send_message("abc", message) 19 | 20 | self.assertEqual(p.call_count, 1) 21 | 22 | call = p.call_args 23 | kwargs = call[1] 24 | 25 | self.assertTrue("dry_run" in kwargs) 26 | self.assertFalse(kwargs["dry_run"]) 27 | self.assertTrue("app" in kwargs) 28 | self.assertIsNone(kwargs["app"]) 29 | 30 | # only one message 31 | messages = call[0][0] 32 | self.assertEqual(len(messages), 1) 33 | 34 | message = messages[0] 35 | self.assertIsInstance(message, Message) 36 | self.assertEqual(message.token, "abc") 37 | self.assertEqual(message.android.notification.body, "Hello world") 38 | 39 | def test_fcm_push_payload_many(self): 40 | with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: 41 | message = dict_to_fcm_message({"message": "Hello world"}) 42 | 43 | send_message(["abc", "123"], message) 44 | 45 | # one call 46 | self.assertEqual(p.call_count, 1) 47 | call = p.call_args 48 | kwargs = call[1] 49 | 50 | self.assertTrue("dry_run" in kwargs) 51 | self.assertFalse(kwargs["dry_run"]) 52 | self.assertTrue("app" in kwargs) 53 | self.assertIsNone(kwargs["app"]) 54 | 55 | # two message 56 | messages = call[0][0] 57 | self.assertEqual(len(messages), 2) 58 | 59 | message_one = messages[0] 60 | self.assertIsInstance(message_one, Message) 61 | self.assertEqual(message_one.token, "abc") 62 | self.assertEqual(message_one.android.notification.body, "Hello world") 63 | 64 | message_two = messages[1] 65 | self.assertIsInstance(message_two, Message) 66 | self.assertEqual( message_two.token,"123") 67 | self.assertEqual( message_two.android.notification.body, "Hello world") 68 | 69 | def test_push_payload_with_app_id(self): 70 | with self.assertRaises(ImproperlyConfigured) as ic: 71 | send_message("abc", {"message": "Hello world"}, application_id="test") 72 | 73 | self.assertEqual( 74 | str(ic.exception), 75 | ("LegacySettings does not support application_id. To enable " 76 | "multiple application support, use push_notifications.conf.AppSettings.") 77 | ) 78 | -------------------------------------------------------------------------------- /tests/test_legacy_config.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | 5 | from push_notifications.conf import get_manager 6 | from push_notifications.exceptions import WebPushError 7 | from push_notifications.webpush import webpush_send_message 8 | 9 | 10 | class LegacyConfigTestCase(TestCase): 11 | 12 | def test_immutable_wp_claims(self): 13 | self.endpoint = "https://updates.push.services.mozilla.com/wpush/v2/token" 14 | self.mock_device = mock.Mock() 15 | self.mock_device.application_id = None 16 | self.mock_device.registration_id = self.endpoint 17 | self.mock_device.auth = "authtest" 18 | self.mock_device.p256dh = "p256dhtest" 19 | self.mock_device.active = True 20 | self.mock_device.save.return_value = True 21 | vapid_claims_pre = get_manager().get_wp_claims(None).copy() 22 | try: 23 | webpush_send_message(self.mock_device, "message") 24 | except WebPushError: 25 | pass 26 | vapid_claims_after = get_manager().get_wp_claims(None) 27 | self.assertDictEqual(vapid_claims_pre, vapid_claims_after) 28 | -------------------------------------------------------------------------------- /tests/test_rest_framework.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from push_notifications.api.rest_framework import ( 4 | APNSDeviceSerializer, GCMDeviceSerializer, ValidationError 5 | ) 6 | 7 | 8 | GCM_DRF_INVALID_HEX_ERROR = {"device_id": ["Device ID is not a valid hex number"]} 9 | GCM_DRF_OUT_OF_RANGE_ERROR = {"device_id": ["Device ID is out of range"]} 10 | 11 | 12 | class APNSDeviceSerializerTestCase(TestCase): 13 | def test_validation(self): 14 | # valid data - 64 bytes upper case 15 | serializer = APNSDeviceSerializer(data={ 16 | "registration_id": "AEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAE", 17 | "name": "Apple iPhone 6+", 18 | "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 19 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 20 | }) 21 | self.assertTrue(serializer.is_valid()) 22 | 23 | # valid data - 64 bytes lower case 24 | serializer = APNSDeviceSerializer(data={ 25 | "registration_id": "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", 26 | "name": "Apple iPhone 6+", 27 | "device_id": "ffffffffffffffffffffffffffffffff", 28 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 29 | }) 30 | self.assertTrue(serializer.is_valid()) 31 | 32 | # valid data - 100 bytes upper case 33 | serializer = APNSDeviceSerializer(data={ 34 | "registration_id": "AE" * 50, 35 | "name": "Apple iPhone 6+", 36 | "device_id": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 37 | }) 38 | self.assertTrue(serializer.is_valid()) 39 | 40 | # valid data - 100 bytes lower case 41 | serializer = APNSDeviceSerializer(data={ 42 | "registration_id": "ae" * 50, 43 | "name": "Apple iPhone 6+", 44 | "device_id": "ffffffffffffffffffffffffffffffff", 45 | }) 46 | self.assertTrue(serializer.is_valid()) 47 | 48 | # valid data - 200 bytes mixed case 49 | serializer = APNSDeviceSerializer(data={ 50 | "registration_id": "aE" * 100, 51 | "name": "Apple iPhone 6+", 52 | "device_id": "ffffffffffffffffffffffffffffffff", 53 | }) 54 | self.assertTrue(serializer.is_valid()) 55 | 56 | # valid data - 200 bytes mixed case 57 | serializer = APNSDeviceSerializer(data={ 58 | "registration_id": "aE" * 100, 59 | "name": "Apple iPhone 6+", 60 | "device_id": "ffffffffffffffffffffffffffffffff", 61 | }) 62 | self.assertTrue(serializer.is_valid()) 63 | 64 | # invalid data - device_id, registration_id 65 | serializer = APNSDeviceSerializer(data={ 66 | "registration_id": "invalid device token contains no hex", 67 | "name": "Apple iPhone 6+", 68 | "device_id": "ffffffffffffffffffffffffffffake", 69 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 70 | }) 71 | self.assertFalse(serializer.is_valid()) 72 | 73 | 74 | class GCMDeviceSerializerTestCase(TestCase): 75 | def test_device_id_validation_pass(self): 76 | serializer = GCMDeviceSerializer(data={ 77 | "registration_id": "foobar", 78 | "name": "Galaxy Note 3", 79 | "device_id": "0x1031af3b", 80 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 81 | }) 82 | self.assertTrue(serializer.is_valid()) 83 | 84 | def test_registration_id_unique(self): 85 | """Validate that a duplicate registration id raises a validation error.""" 86 | 87 | # add a device 88 | serializer = GCMDeviceSerializer(data={ 89 | "registration_id": "foobar", 90 | "name": "Galaxy Note 3", 91 | "device_id": "0x1031af3b", 92 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 93 | }) 94 | serializer.is_valid(raise_exception=True) 95 | obj = serializer.save() 96 | 97 | # ensure updating the same object works 98 | serializer = GCMDeviceSerializer(obj, data={ 99 | "registration_id": "foobar", 100 | "name": "Galaxy Note 5", 101 | "device_id": "0x1031af3b", 102 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 103 | }) 104 | serializer.is_valid(raise_exception=True) 105 | obj = serializer.save() 106 | 107 | # try to add a new device with the same token 108 | serializer = GCMDeviceSerializer(data={ 109 | "registration_id": "foobar", 110 | "name": "Galaxy Note 3", 111 | "device_id": "0xdeadbeaf", 112 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 113 | }) 114 | 115 | with self.assertRaises(ValidationError): 116 | serializer.is_valid(raise_exception=True) 117 | 118 | def test_device_id_validation_fail_bad_hex(self): 119 | serializer = GCMDeviceSerializer(data={ 120 | "registration_id": "foobar", 121 | "name": "Galaxy Note 3", 122 | "device_id": "0x10r", 123 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 124 | }) 125 | self.assertFalse(serializer.is_valid()) 126 | self.assertEqual(serializer.errors, GCM_DRF_INVALID_HEX_ERROR) 127 | 128 | def test_device_id_validation_fail_out_of_range(self): 129 | serializer = GCMDeviceSerializer(data={ 130 | "registration_id": "foobar", 131 | "name": "Galaxy Note 3", 132 | "device_id": "10000000000000000", # 2**64 133 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 134 | }) 135 | self.assertFalse(serializer.is_valid()) 136 | self.assertEqual(serializer.errors, GCM_DRF_OUT_OF_RANGE_ERROR) 137 | 138 | def test_device_id_validation_value_between_signed_unsigned_64b_int_maximums(self): 139 | """ 140 | 2**63 < 0xe87a4e72d634997c < 2**64 141 | """ 142 | serializer = GCMDeviceSerializer(data={ 143 | "registration_id": "foobar", 144 | "name": "Nexus 5", 145 | "device_id": "e87a4e72d634997c", 146 | "application_id": "XXXXXXXXXXXXXXXXXXXX", 147 | }) 148 | self.assertTrue(serializer.is_valid()) 149 | -------------------------------------------------------------------------------- /tests/test_webpush.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | from pywebpush import WebPushException 5 | 6 | from push_notifications.exceptions import WebPushError 7 | from push_notifications.webpush import ( 8 | get_subscription_info, webpush_send_message 9 | ) 10 | 11 | # Mock Responses 12 | mock_success_response = mock.MagicMock(status_code=200, ok=True) 13 | mock_fail_resposne = mock.MagicMock(status_code=400, ok=False, content="Test Error") 14 | mock_unsubscribe_response = mock.MagicMock( 15 | status_code=410, ok=False, content="Unsubscribe") 16 | mock_unsubscribe_response_404 = mock.MagicMock( 17 | status_code=404, ok=False, content="Unsubscribe") 18 | 19 | 20 | class WebPushSendMessageTestCase(TestCase): 21 | def setUp(self): 22 | self.endpoint = "https://updates.push.services.mozilla.com/wpush/v2/token" 23 | self.mock_device = mock.Mock() 24 | self.mock_device.application_id = None 25 | self.mock_device.registration_id = self.endpoint 26 | self.mock_device.auth = "authtest" 27 | self.mock_device.p256dh = "p256dhtest" 28 | self.mock_device.active = True 29 | self.mock_device.save.return_value = True 30 | 31 | def test_get_subscription_info(self): 32 | keys = {"auth": "authtest", "p256dh": "p256dhtest"} 33 | endpoint = self.endpoint 34 | original = get_subscription_info( 35 | None, "token", "FIREFOX", keys["auth"], keys["p256dh"] 36 | ) 37 | 38 | self.assertEqual( 39 | original, 40 | { 41 | "endpoint": endpoint, 42 | "keys": keys, 43 | }, 44 | ) 45 | 46 | patched = get_subscription_info( 47 | None, 48 | endpoint, 49 | "", 50 | keys["auth"], 51 | keys["p256dh"], 52 | ) 53 | 54 | self.assertEqual( 55 | patched, 56 | { 57 | "endpoint": endpoint, 58 | "keys": keys, 59 | }, 60 | ) 61 | 62 | @mock.patch("push_notifications.webpush.webpush", return_value=mock_success_response) 63 | def test_webpush_send_message(self, webpush_mock): 64 | results = webpush_send_message(self.mock_device, "message") 65 | self.assertEqual(results["success"], 1) 66 | 67 | @mock.patch("push_notifications.webpush.webpush", return_value=mock_fail_resposne) 68 | def test_webpush_send_message_failure(self, webpush_mock): 69 | results = webpush_send_message(self.mock_device, "message") 70 | self.assertEqual(results["failure"], 1) 71 | 72 | @mock.patch( 73 | "push_notifications.webpush.webpush", 74 | side_effect=WebPushException("Unsubscribe", 75 | response=mock_unsubscribe_response)) 76 | def test_webpush_send_message_unsubscribe(self, webpush_mock): 77 | results = webpush_send_message(self.mock_device, "message") 78 | self.assertEqual(results["failure"], 1) 79 | 80 | @mock.patch( 81 | "push_notifications.webpush.webpush", 82 | side_effect=WebPushException("Unsubscribe", 83 | response=mock_unsubscribe_response_404)) 84 | def test_webpush_send_message_404(self, webpush_mock): 85 | results = webpush_send_message(self.mock_device, "message") 86 | self.assertEqual(results["failure"], 1) 87 | 88 | @mock.patch( 89 | "push_notifications.webpush.webpush", 90 | side_effect=WebPushException("Error")) 91 | def test_webpush_send_message_exception(self, webpush_mock): 92 | with self.assertRaises(WebPushError): 93 | webpush_send_message(self.mock_device, "message") 94 | -------------------------------------------------------------------------------- /tests/test_wns.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import xml.etree.ElementTree as ET 3 | 4 | from django.test import TestCase 5 | 6 | from push_notifications.wns import ( 7 | dict_to_xml_schema, wns_send_bulk_message, wns_send_message 8 | ) 9 | 10 | 11 | class WNSSendMessageTestCase(TestCase): 12 | def setUp(self): 13 | pass 14 | 15 | @mock.patch("push_notifications.wns._wns_prepare_toast", return_value="this is expected") 16 | @mock.patch("push_notifications.wns._wns_send") 17 | def test_send_message_calls_wns_send_with_toast(self, mock_method, _): 18 | wns_send_message(uri="one", message="test message") 19 | mock_method.assert_called_with( 20 | application_id=None, uri="one", data="this is expected", wns_type="wns/toast" 21 | ) 22 | 23 | @mock.patch("push_notifications.wns._wns_prepare_toast", return_value="this is expected") 24 | @mock.patch("push_notifications.wns._wns_send") 25 | def test_send_message_calls_wns_send_with_application_id(self, mock_method, _): 26 | wns_send_message(uri="one", message="test message", application_id="123456") 27 | mock_method.assert_called_with( 28 | application_id="123456", uri="one", data="this is expected", wns_type="wns/toast" 29 | ) 30 | 31 | @mock.patch("push_notifications.wns.dict_to_xml_schema", return_value=ET.Element("toast")) 32 | @mock.patch("push_notifications.wns._wns_send") 33 | def test_send_message_calls_wns_send_with_xml(self, mock_method, _): 34 | wns_send_message(uri="one", xml_data={"key": "value"}) 35 | mock_method.assert_called_with( 36 | application_id=None, uri="one", data=b"", wns_type="wns/toast" 37 | ) 38 | 39 | def test_send_message_raises_TypeError_if_one_of_the_data_params_arent_filled(self): 40 | with self.assertRaises(TypeError): 41 | wns_send_message(uri="one") 42 | 43 | 44 | class WNSSendBulkMessageTestCase(TestCase): 45 | def setUp(self): 46 | pass 47 | 48 | @mock.patch("push_notifications.wns.wns_send_message") 49 | def test_send_bulk_message_doesnt_call_send_message_with_empty_list(self, mock_method): 50 | wns_send_bulk_message(uri_list=[], message="test message") 51 | mock_method.assert_not_called() 52 | 53 | @mock.patch("push_notifications.wns.wns_send_message") 54 | def test_send_bulk_message_calls_send_message(self, mock_method): 55 | wns_send_bulk_message(uri_list=["one", ], message="test message") 56 | mock_method.assert_called_with( 57 | application_id=None, message="test message", raw_data=None, uri="one", xml_data=None 58 | ) 59 | 60 | 61 | class WNSDictToXmlSchemaTestCase(TestCase): 62 | def setUp(self): 63 | pass 64 | 65 | def test_create_simple_xml_from_dict(self): 66 | xml_data = { 67 | "toast": { 68 | "attrs": {"key": "value"}, 69 | "children": { 70 | "visual": { 71 | "children": { 72 | "binding": { 73 | "attrs": {"template": "ToastText01"}, 74 | "children": { 75 | "text": { 76 | "attrs": {"id": "1"}, 77 | "children": "toast notification" 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | # Converting xml to str via tostring is inconsistent, so we have to check each element. 87 | xml_tree = dict_to_xml_schema(xml_data) 88 | self.assertEqual(xml_tree.tag, "toast") 89 | self.assertEqual(xml_tree.attrib, {"key": "value"}) 90 | visual = list(xml_tree)[0] 91 | self.assertEqual(visual.tag, "visual") 92 | binding = list(visual)[0] 93 | self.assertEqual(binding.tag, "binding") 94 | self.assertEqual(binding.attrib, {"template": "ToastText01"}) 95 | text = list(binding)[0] 96 | self.assertEqual(text.tag, "text") 97 | self.assertEqual(text.attrib, {"id": "1"}) 98 | self.assertEqual(text.text, "toast notification") 99 | 100 | def test_create_multi_sub_element_xml_from_dict(self): 101 | xml_data = { 102 | "toast": { 103 | "attrs": { 104 | "key": "value" 105 | }, 106 | "children": { 107 | "visual": { 108 | "children": { 109 | "binding": { 110 | "attrs": {"template": "ToastText02"}, 111 | "children": { 112 | "text": [ 113 | {"attrs": {"id": "1"}, "children": "first text"}, 114 | {"attrs": {"id": "2"}, "children": "second text"}, 115 | ] 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } 123 | # Converting xml to str via tostring is inconsistent, so we have to check each element. 124 | xml_tree = dict_to_xml_schema(xml_data) 125 | self.assertEqual(xml_tree.tag, "toast") 126 | self.assertEqual(xml_tree.attrib, {"key": "value"}) 127 | visual = list(xml_tree)[0] 128 | self.assertEqual(visual.tag, "visual") 129 | binding = list(visual)[0] 130 | self.assertEqual(binding.tag, "binding") 131 | self.assertEqual(binding.attrib, {"template": "ToastText02"}) 132 | self.assertEqual(len(list(binding)), 2) 133 | 134 | def test_create_two_multi_sub_element_xml_from_dict(self): 135 | xml_data = { 136 | "toast": { 137 | "attrs": { 138 | "key": "value" 139 | }, 140 | "children": { 141 | "visual": { 142 | "children": { 143 | "binding": { 144 | "attrs": { 145 | "template": "ToastText02" 146 | }, 147 | "children": { 148 | "text": [ 149 | {"attrs": {"id": "1"}, "children": "first text"}, 150 | {"attrs": {"id": "2"}, "children": "second text"}, 151 | ], 152 | "image": [ 153 | {"attrs": {"src": "src1"}}, 154 | {"attrs": {"src": "src2"}}, 155 | ] 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | # Converting xml to str via tostring is inconsistent, so we have to check each element. 164 | xml_tree = dict_to_xml_schema(xml_data) 165 | self.assertEqual(xml_tree.tag, "toast") 166 | self.assertEqual(xml_tree.attrib, {"key": "value"}) 167 | visual = list(xml_tree)[0] 168 | self.assertEqual(visual.tag, "visual") 169 | binding = list(visual)[0] 170 | self.assertEqual(binding.tag, "binding") 171 | self.assertEqual(binding.attrib, {"template": "ToastText02"}) 172 | self.assertEqual(len(list(binding)), 4) 173 | -------------------------------------------------------------------------------- /tests/tst_unique.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import IntegrityError 3 | from django.test import TestCase 4 | from push_notifications.models import APNSDevice, GCMDevice, WNSDevice, WebPushDevice 5 | 6 | 7 | class GCMModelTestCase(TestCase): 8 | def test_throws_error_for_same_gcm_registration_id(self): 9 | device = GCMDevice.objects.create( 10 | registration_id="unique_id", cloud_message_type="GCM" 11 | ) 12 | assert device.id is not None 13 | with pytest.raises(IntegrityError) as excinfo: 14 | GCMDevice.objects.create( 15 | registration_id="unique_id", cloud_message_type="GCM" 16 | ) 17 | assert "UNIQUE constraint failed" in str(excinfo.value) 18 | 19 | def test_throws_error_for_same_apns_registration_id(self): 20 | device = APNSDevice.objects.create( 21 | registration_id="unique_id", 22 | ) 23 | assert device.id is not None 24 | with pytest.raises(IntegrityError) as excinfo: 25 | APNSDevice.objects.create( 26 | registration_id="unique_id", 27 | ) 28 | assert "UNIQUE constraint failed" in str(excinfo.value) 29 | 30 | def test_throws_error_for_same_wns_registration_id(self): 31 | device = WNSDevice.objects.create( 32 | registration_id="unique_id", 33 | ) 34 | assert device.id is not None 35 | with pytest.raises(IntegrityError) as excinfo: 36 | WNSDevice.objects.create( 37 | registration_id="unique_id", 38 | ) 39 | assert "UNIQUE constraint failed" in str(excinfo.value) 40 | 41 | def test_throws_error_for_same_web_registration_id(self): 42 | device = WebPushDevice.objects.create( 43 | registration_id="unique_id", 44 | ) 45 | assert device.id is not None 46 | with pytest.raises(IntegrityError) as excinfo: 47 | WebPushDevice.objects.create( 48 | registration_id="unique_id", 49 | ) 50 | assert "UNIQUE constraint failed" in str(excinfo.value) 51 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = False 3 | usedevelop = true 4 | envlist = 5 | py{37,38,39}-dj{22,32} 6 | py{38,39}-dj{40,405} 7 | py{310,311}-dj{40,405} 8 | flake8 9 | 10 | [gh-actions] 11 | python = 12 | 3.7: py37 13 | 3.8: py38 14 | 3.9: py39, flake8 15 | 3.10: py310 16 | 3.11: py311 17 | 18 | [gh-actions:env] 19 | DJANGO = 20 | 2.2: dj22 21 | 3.2: dj32 22 | 4.0: dj40 23 | 4.0.5: dj405 24 | 4.2: dj42 25 | 26 | [testenv] 27 | usedevelop = true 28 | setenv = 29 | PYTHONWARNINGS = all 30 | DJANGO_SETTINGS_MODULE = tests.settings 31 | PYTHONPATH = {toxinidir} 32 | commands = 33 | pytest 34 | pytest --ds=tests.settings_unique tests/tst_unique.py 35 | deps = 36 | pytest 37 | pytest-cov 38 | pytest-django 39 | pywebpush 40 | djangorestframework 41 | firebase-admin>=6.2 42 | dj22: Django>=2.2,<3.0 43 | dj32: Django>=3.2,<3.3 44 | dj40: Django>=4.0,<4.0.5 45 | dj405: Django>=4.0.5,<4.1 46 | dj42: Django>=4.2,<4.3 47 | py{36,37,38,39}: apns2 48 | py{310,311}: aioapns>=3.1,<3.2 49 | 50 | [testenv:flake8] 51 | commands = flake8 --exit-zero 52 | deps = 53 | flake8 54 | flake8-isort 55 | flake8-quotes 56 | 57 | [flake8] 58 | ignore = W191,E503 59 | max-line-length = 92 60 | exclude = .tox, push_notifications/migrations 61 | inline-quotes = double 62 | 63 | [isort] 64 | indent = tab 65 | line_length = 92 66 | lines_after_imports = 2 67 | balanced_wrapping = True 68 | default_section = THIRDPARTY 69 | known_first_party = push_notifications 70 | multi_line_output = 5 71 | skip = .tox/ 72 | --------------------------------------------------------------------------------