├── tests ├── __init__.py ├── responses.py ├── settings.py ├── settings_unique.py ├── test_legacy_config.py ├── test_fields.py ├── tst_unique.py ├── test_data │ ├── without_private.pem │ ├── good_with_passwd.pem │ └── good_revoked.pem ├── test_gcm_push_payload.py ├── test_webpush.py ├── test_dict_to_message.py ├── test_admin.py ├── test_apns_push_payload.py ├── test_rest_framework.py ├── test_wns.py ├── test_apns_models.py ├── test_app_config.py ├── test_apns_async_models.py └── test_apns_async_push_payload.py ├── push_notifications ├── py.typed ├── api │ ├── __init__.py │ └── rest_framework.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20160106_0850.py │ ├── 0009_alter_apnsdevice_device_id.py │ ├── 0011_alter_apnsdevice_registration_id.py │ ├── 0004_fcm.py │ ├── 0008_webpush_add_edge.py │ ├── 0012_alter_webpushdevice_browser.py │ ├── 0010_alter_gcmdevice_options_and_more.py │ ├── 0007_uniquesetting.py │ ├── 0005_applicationid.py │ ├── 0003_wnsdevice.py │ ├── 0006_webpushdevice.py │ └── 0001_initial.py ├── compat.py ├── apps.py ├── conf │ ├── appmodel.py │ ├── __init__.py │ ├── base.py │ ├── legacy.py │ └── app.py ├── __init__.py ├── exceptions.py ├── settings.py ├── webpush.py ├── fields.py ├── admin.py ├── apns.py ├── gcm.py ├── models.py └── wns.py ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── pyproject.toml ├── .gitignore ├── .editorconfig ├── setup.py ├── .pre-commit-config.yaml ├── ACKNOWLEDGEMENTS.md ├── LICENSE ├── tox.ini ├── setup.cfg ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── docs ├── APNS.rst ├── FCM.rst └── WebPush.rst └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /push_notifications/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /push_notifications/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /push_notifications/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /push_notifications/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PushNotificationsConfig(AppConfig): 5 | name = "push_notifications" 6 | default_auto_field = "django.db.models.AutoField" 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | 28 | dist/ 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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: v6.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.21.2 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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | 4 | try: 5 | # Python 3.8+ 6 | import importlib.metadata as importlib_metadata 7 | except ImportError: 8 | # None: 3 | super().__init__(message) 4 | self.message = message 5 | 6 | # APNS 7 | class APNSError(NotificationError): 8 | pass 9 | 10 | 11 | class APNSUnsupportedPriority(APNSError): 12 | pass 13 | 14 | 15 | class APNSServerError(APNSError): 16 | def __init__(self, status: str) -> None: 17 | super().__init__(status) 18 | self.status = status 19 | 20 | 21 | # GCM 22 | class GCMError(NotificationError): 23 | pass 24 | 25 | 26 | # Web Push 27 | class WebPushError(NotificationError): 28 | pass 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/0012_alter_webpushdevice_browser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-06-16 11:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('push_notifications', '0011_alter_apnsdevice_registration_id'), 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'), ('SAFARI', 'Safari')], default='CHROME', help_text='Currently only support to Chrome, Firefox, Edge, Safari and Opera browsers', max_length=10, verbose_name='Browser'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /push_notifications/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 3 | from typing import Union, Optional 4 | from .app import AppConfig 5 | from .appmodel import AppModelConfig 6 | from .legacy import LegacyConfig 7 | 8 | # ManagerType is an alias for the possible configuration manager classes 9 | # that can be loaded dynamically via SETTINGS["CONFIG"]. 10 | ManagerType = Union[AppConfig, AppModelConfig, LegacyConfig] 11 | 12 | manager: Optional[ManagerType] = None 13 | 14 | 15 | def get_manager(reload: bool = False) -> ManagerType: 16 | global manager 17 | if not manager or reload: 18 | manager = import_string(SETTINGS["CONFIG"])() 19 | return manager 20 | 21 | 22 | # implementing get_manager as a function allows tests to reload settings 23 | get_manager() 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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-22.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@v6 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 3.12: py312 18 | 3.13: py313 19 | 20 | [gh-actions:env] 21 | DJANGO = 22 | 2.2: dj22 23 | 3.2: dj32 24 | 4.0: dj40 25 | 4.0.5: dj405 26 | 4.2: dj42 27 | 28 | [testenv] 29 | usedevelop = true 30 | setenv = 31 | PYTHONWARNINGS = all 32 | DJANGO_SETTINGS_MODULE = tests.settings 33 | PYTHONPATH = {toxinidir} 34 | commands = 35 | pytest 36 | pytest --ds=tests.settings_unique tests/tst_unique.py 37 | deps = 38 | pytest 39 | pytest-cov 40 | pytest-django 41 | pywebpush 42 | djangorestframework 43 | firebase-admin>=6.2 44 | dj22: Django>=2.2,<3.0 45 | dj32: Django>=3.2,<3.3 46 | dj40: Django>=4.0,<4.0.5 47 | dj405: Django>=4.0.5,<4.1 48 | dj42: Django>=4.2,<4.3 49 | py{36,37,38,39}: apns2 50 | py{310,311,312,313}: aioapns>=3.1,<3.2 51 | 52 | [testenv:flake8] 53 | commands = flake8 --exit-zero 54 | deps = 55 | flake8 56 | flake8-isort 57 | flake8-quotes 58 | 59 | [flake8] 60 | ignore = W191,E503 61 | max-line-length = 92 62 | exclude = .tox, push_notifications/migrations 63 | inline-quotes = double 64 | 65 | [isort] 66 | indent = tab 67 | line_length = 92 68 | lines_after_imports = 2 69 | balanced_wrapping = True 70 | default_section = THIRDPARTY 71 | known_first_party = push_notifications 72 | multi_line_output = 5 73 | skip = .tox/ 74 | -------------------------------------------------------------------------------- /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 | Programming Language :: Python :: 3.12 27 | Programming Language :: Python :: 3.13 28 | Topic :: Internet :: WWW/HTTP 29 | Topic :: System :: Networking 30 | 31 | [options] 32 | python_requires = >= 3.7 33 | packages = find: 34 | install_requires = 35 | Django>=2.2 36 | 37 | setup_requires = 38 | setuptools_scm 39 | 40 | [options.extras_require] 41 | APNS = 42 | apns2>=0.3.0 43 | importlib-metadata;python_version < "3.8" 44 | Django>=2.2 45 | 46 | WP = pywebpush>=1.3.0 47 | 48 | apns-async = aioapns>=3.1,<4.0 49 | 50 | FCM = firebase-admin>=6.2 51 | APNS_ASYNC = aioapns>=3.1,<4.0 52 | 53 | 54 | [options.packages.find] 55 | exclude = tests 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | "SAFARI": "https://web.push.apple.com", 41 | }) 42 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_PRIVATE_KEY", None) 43 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_CLAIMS", None) 44 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_ERROR_TIMEOUT", 1) 45 | 46 | # User model 47 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("USER_MODEL", settings.AUTH_USER_MODEL) 48 | 49 | # Unique registration ID for all devices 50 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("UNIQUE_REG_ID", False) 51 | 52 | # API endpoint settings 53 | PUSH_NOTIFICATIONS_SETTINGS.setdefault("UPDATE_ON_DUPLICATE_REG_ID", False) 54 | -------------------------------------------------------------------------------- /push_notifications/conf/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from typing import Optional, Any, Collection 3 | 4 | 5 | class BaseConfig: 6 | 7 | def get_firebase_app(self, application_id: Optional[str] = None) -> Any: 8 | raise NotImplementedError 9 | 10 | def has_auth_token_creds(self, application_id: Optional[str] = None) -> bool: 11 | raise NotImplementedError 12 | 13 | def get_apns_certificate(self, application_id: Optional[str] = None) -> str: 14 | raise NotImplementedError 15 | 16 | def get_apns_auth_creds(self, application_id: Optional[str] = None) -> Any: 17 | raise NotImplementedError 18 | 19 | def get_apns_use_sandbox(self, application_id: Optional[str] = None) -> bool: 20 | raise NotImplementedError 21 | 22 | def get_apns_use_alternative_port(self, application_id: Optional[str] = None) -> bool: 23 | raise NotImplementedError 24 | 25 | def get_wns_package_security_id(self, application_id: Optional[str] = None) -> str: 26 | raise NotImplementedError 27 | 28 | def get_wns_secret_key(self, application_id: Optional[str] = None) -> str: 29 | raise NotImplementedError 30 | 31 | def get_max_recipients(self, application_id: Optional[str] = None) -> int: 32 | raise NotImplementedError 33 | 34 | def get_applications(self) -> Collection[str]: 35 | """Returns a collection containing the configured applications.""" 36 | 37 | raise NotImplementedError 38 | 39 | 40 | # This works for both the certificate and the auth key (since that's just 41 | # a certificate). 42 | def check_apns_certificate(ss: str) -> None: 43 | mode = "start" 44 | for s in ss.split("\n"): 45 | if mode == "start": 46 | if "BEGIN RSA PRIVATE KEY" in s or "BEGIN PRIVATE KEY" in s: 47 | mode = "key" 48 | elif mode == "key": 49 | if "END RSA PRIVATE KEY" in s or "END PRIVATE KEY" in s: 50 | mode = "end" 51 | break 52 | elif s.startswith("Proc-Type") and "ENCRYPTED" in s: 53 | raise ImproperlyConfigured("Encrypted APNS private keys are not supported") 54 | 55 | if mode != "end": 56 | raise ImproperlyConfigured("The APNS certificate doesn't contain a private key") 57 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /push_notifications/webpush.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from pywebpush import WebPushException, webpush 4 | from typing import Dict, Any 5 | from .conf import get_manager 6 | from .exceptions import WebPushError 7 | 8 | 9 | def get_subscription_info( 10 | application_id: str, uri: str, browser: str, auth: str, p256dh: str 11 | ) -> Dict[str, Any]: 12 | if uri.startswith("https://"): 13 | endpoint = uri 14 | else: 15 | manager = get_manager() 16 | if hasattr(manager, "get_wp_post_url"): 17 | url = manager.get_wp_post_url(application_id, browser) 18 | else: 19 | raise AttributeError("Manager does not support get_wp_post_url method") 20 | endpoint = "{}/{}".format(url, uri) 21 | warnings.warn( 22 | "registration_id should be the full endpoint returned from pushManager.subscribe", 23 | DeprecationWarning, 24 | stacklevel=2, 25 | ) 26 | return { 27 | "endpoint": endpoint, 28 | "keys": { 29 | "auth": auth, 30 | "p256dh": p256dh, 31 | }, 32 | } 33 | 34 | 35 | def webpush_send_message(device: Any, message: str, **kwargs: Any) -> Dict[str, Any]: 36 | subscription_info = get_subscription_info( 37 | device.application_id, 38 | device.registration_id, 39 | device.browser, 40 | device.auth, 41 | device.p256dh, 42 | ) 43 | try: 44 | results = {"results": [{"original_registration_id": device.registration_id}]} 45 | manager = get_manager() 46 | 47 | vapid_private_key = None 48 | if hasattr(manager, "get_wp_private_key"): 49 | vapid_private_key = manager.get_wp_private_key(device.application_id) 50 | 51 | vapid_claims = None 52 | if hasattr(manager, "get_wp_claims"): 53 | vapid_claims = manager.get_wp_claims(device.application_id).copy() 54 | 55 | timeout = None 56 | if hasattr(manager, "get_wp_error_timeout"): 57 | timeout = manager.get_wp_error_timeout(device.application_id) 58 | 59 | response = webpush( 60 | subscription_info=subscription_info, 61 | data=message, 62 | vapid_private_key=vapid_private_key, 63 | vapid_claims=vapid_claims, 64 | timeout=timeout, 65 | **kwargs, 66 | ) 67 | if response.ok: 68 | results["success"] = 1 69 | else: 70 | results["failure"] = 1 71 | results["results"][0]["error"] = response.content 72 | return results 73 | except WebPushException as e: 74 | if e.response is not None and e.response.status_code in [404, 410]: 75 | results["failure"] = 1 76 | results["results"][0]["error"] = e.message 77 | device.active = False 78 | device.save() 79 | return results 80 | raise WebPushError(e.message) 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_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 | -------------------------------------------------------------------------------- /push_notifications/fields.py: -------------------------------------------------------------------------------- 1 | import re 2 | import struct 3 | from typing import Optional, Any 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() -> bool: 24 | return connection.vendor in signed_integer_vendors 25 | 26 | 27 | def _signed_to_unsigned_integer(value: int) -> int: 28 | return struct.unpack("Q", struct.pack("q", value))[0] 29 | 30 | 31 | def _unsigned_to_signed_integer(value: int) -> int: 32 | return struct.unpack("q", struct.pack("Q", value))[0] 33 | 34 | 35 | def _hex_string_to_unsigned_integer(value: str) -> int: 36 | return int(value, 16) 37 | 38 | 39 | def _unsigned_integer_to_hex_string(value: int) -> str: 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 | 48 | def __init__(self, *args: Any, **kwargs: Any) -> None: 49 | self.default_validators = [ 50 | RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid") 51 | ] 52 | super().__init__(*args, **kwargs) 53 | 54 | def prepare_value(self, value: Optional[Any]) -> str: 55 | # converts bigint from db to hex before it is displayed in admin 56 | if ( 57 | value 58 | and not isinstance(value, str) 59 | and connection.vendor in ("mysql", "sqlite") 60 | ): 61 | value = _unsigned_integer_to_hex_string(value) 62 | return super(forms.CharField, self).prepare_value(value) 63 | 64 | 65 | class HexIntegerField(models.BigIntegerField): 66 | """ 67 | This field stores a hexadecimal *string* of up to 64 bits as an unsigned integer 68 | on *all* backends including postgres. 69 | 70 | Reasoning: Postgres only supports signed bigints. Since we don't care about 71 | signedness, we store it as signed, and cast it to unsigned when we deal with 72 | the actual value (with struct) 73 | 74 | On sqlite and mysql, native unsigned bigint types are used. In all cases, the 75 | value we deal with in python is always in hex. 76 | """ 77 | 78 | validators = [ 79 | MinValueValidator(UNSIGNED_64BIT_INT_MIN_VALUE), 80 | MaxValueValidator(UNSIGNED_64BIT_INT_MAX_VALUE), 81 | ] 82 | 83 | def db_type(self, connection: Any) -> str: 84 | if "mysql" == connection.vendor: 85 | return "bigint unsigned" 86 | elif "sqlite" == connection.vendor: 87 | return "UNSIGNED BIG INT" 88 | else: 89 | return super().db_type(connection=connection) 90 | 91 | def get_prep_value(self, value: Optional[Any]) -> Optional[int]: 92 | """Return the integer value to be stored from the hex string""" 93 | if value is None or value == "": 94 | return None 95 | if isinstance(value, str): 96 | value = _hex_string_to_unsigned_integer(value) 97 | if _using_signed_storage(): 98 | value = _unsigned_to_signed_integer(value) 99 | return value 100 | 101 | def from_db_value(self, value: Optional[int], *args: Any) -> Optional[int]: 102 | """Return an unsigned int representation from all db backends""" 103 | if value is None: 104 | return value 105 | if _using_signed_storage(): 106 | value = _signed_to_unsigned_integer(value) 107 | return value 108 | 109 | def to_python(self, value: Optional[Any]) -> Optional[str]: 110 | """Return a str representation of the hexadecimal""" 111 | if isinstance(value, str): 112 | return value 113 | if value is None: 114 | return value 115 | return _unsigned_integer_to_hex_string(value) 116 | 117 | def formfield(self, **kwargs: Any) -> HexadecimalField: 118 | defaults = {"form_class": HexadecimalField} 119 | defaults.update(kwargs) 120 | # yes, that super call is right 121 | return super(models.IntegerField, self).formfield(**defaults) 122 | 123 | def run_validators(self, value: Any) -> None: 124 | # make sure validation is performed on integer value not string value 125 | # removed `return` since run_validators() never returns anything, 126 | # it only raises ValidationError on failure 127 | value = _hex_string_to_unsigned_integer(value) 128 | super(models.BigIntegerField, self).run_validators(value) 129 | -------------------------------------------------------------------------------- /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_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_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 | -------------------------------------------------------------------------------- /push_notifications/conf/legacy.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | from typing import Any, Optional, Tuple, Dict 5 | 6 | from .base import BaseConfig 7 | 8 | 9 | __all__ = [ 10 | "LegacyConfig" 11 | ] 12 | 13 | 14 | class empty: 15 | pass 16 | 17 | 18 | class LegacyConfig(BaseConfig): 19 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 20 | 21 | def _get_application_settings(self, application_id: Optional[str], settings_key: str, error_message: str) -> Any: 22 | """Legacy behaviour""" 23 | 24 | if not application_id: 25 | value = SETTINGS.get(settings_key, empty) 26 | if value is empty: 27 | raise ImproperlyConfigured(error_message) 28 | return value 29 | else: 30 | msg = ( 31 | "LegacySettings does not support application_id. To enable " 32 | "multiple application support, use push_notifications.conf.AppSettings." 33 | ) 34 | raise ImproperlyConfigured(msg) 35 | 36 | def get_firebase_app(self, application_id: Optional[str] = None) -> Any: 37 | key = "FIREBASE_APP" 38 | msg = ( 39 | 'Set PUSH_NOTIFICATIONS_SETTINGS["{}"] to send messages through FCM.'.format(key) 40 | ) 41 | return self._get_application_settings(application_id, key, msg) 42 | 43 | def get_max_recipients(self, application_id: Optional[str] = None) -> int: 44 | key = "FCM_MAX_RECIPIENTS" 45 | msg = ( 46 | 'Set PUSH_NOTIFICATIONS_SETTINGS["{}"] to send messages through FCM.'.format(key) 47 | ) 48 | return self._get_application_settings(application_id, key, msg) 49 | 50 | def has_auth_token_creds(self, application_id: Optional[str] = None) -> bool: 51 | try: 52 | self._get_apns_auth_key(application_id) 53 | self._get_apns_auth_key_id(application_id) 54 | self._get_apns_team_id(application_id) 55 | except ImproperlyConfigured: 56 | return False 57 | 58 | return True 59 | 60 | def get_apns_certificate(self, application_id: Optional[str] = None) -> str: 61 | r = self._get_application_settings( 62 | application_id, "APNS_CERTIFICATE", 63 | "You need to setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 64 | ) 65 | if not isinstance(r, str): 66 | # probably the (Django) file, and file path should be got 67 | if hasattr(r, "path"): 68 | return r.path 69 | elif (hasattr(r, "has_key") or hasattr(r, "__contains__")) and "path" in r: 70 | return r["path"] 71 | else: 72 | msg = ( 73 | "The APNS certificate settings value should be a string, or " 74 | "should have a 'path' attribute or key" 75 | ) 76 | raise ImproperlyConfigured(msg) 77 | return r 78 | 79 | def get_apns_auth_creds(self, application_id: Optional[str] = None) -> Tuple[str, str, str]: 80 | return ( 81 | self._get_apns_auth_key(application_id), 82 | self._get_apns_auth_key_id(application_id), 83 | self._get_apns_team_id(application_id)) 84 | 85 | def _get_apns_auth_key(self, application_id: Optional[str] = None) -> str: 86 | return self._get_application_settings(application_id, "APNS_AUTH_KEY_PATH", self.msg) 87 | 88 | def _get_apns_team_id(self, application_id: Optional[str] = None) -> str: 89 | return self._get_application_settings(application_id, "APNS_TEAM_ID", self.msg) 90 | 91 | def _get_apns_auth_key_id(self, application_id: Optional[str] = None) -> str: 92 | return self._get_application_settings(application_id, "APNS_AUTH_KEY_ID", self.msg) 93 | 94 | def get_apns_use_sandbox(self, application_id: Optional[str] = None) -> bool: 95 | return self._get_application_settings(application_id, "APNS_USE_SANDBOX", self.msg) 96 | 97 | def get_apns_use_alternative_port(self, application_id: Optional[str] = None) -> bool: 98 | return self._get_application_settings(application_id, "APNS_USE_ALTERNATIVE_PORT", self.msg) 99 | 100 | def get_apns_topic(self, application_id: Optional[str] = None) -> str: 101 | return self._get_application_settings(application_id, "APNS_TOPIC", self.msg) 102 | 103 | def get_apns_host(self, application_id: Optional[str] = None) -> str: 104 | return self._get_application_settings(application_id, "APNS_HOST", self.msg) 105 | 106 | def get_apns_port(self, application_id: Optional[str] = None) -> int: 107 | return self._get_application_settings(application_id, "APNS_PORT", self.msg) 108 | 109 | def get_apns_feedback_host(self, application_id: Optional[str] = None) -> str: 110 | return self._get_application_settings(application_id, "APNS_FEEDBACK_HOST", self.msg) 111 | 112 | def get_apns_feedback_port(self, application_id: Optional[str] = None) -> int: 113 | return self._get_application_settings(application_id, "APNS_FEEDBACK_PORT", self.msg) 114 | 115 | def get_wns_package_security_id(self, application_id: Optional[str] = None) -> str: 116 | return self._get_application_settings(application_id, "WNS_PACKAGE_SECURITY_ID", self.msg) 117 | 118 | def get_wns_secret_key(self, application_id: Optional[str] = None) -> str: 119 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 120 | return self._get_application_settings(application_id, "WNS_SECRET_KEY", msg) 121 | 122 | def get_wp_post_url(self, application_id: str, browser: str) -> str: 123 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 124 | return self._get_application_settings(application_id, "WP_POST_URL", msg)[browser] 125 | 126 | def get_wp_private_key(self, application_id: Optional[str] = None) -> str: 127 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 128 | return self._get_application_settings(application_id, "WP_PRIVATE_KEY", msg) 129 | 130 | def get_wp_claims(self, application_id: Optional[str] = None) -> Dict[str, Any] : 131 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to send messages" 132 | return self._get_application_settings(application_id, "WP_CLAIMS", msg) 133 | 134 | def get_wp_error_timeout(self, application_id: Optional[str] = None) -> int: 135 | msg = "Setup PUSH_NOTIFICATIONS_SETTINGS properly to set a timeout" 136 | return self._get_application_settings(application_id, "WP_ERROR_TIMEOUT", msg) 137 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /push_notifications/admin.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib import admin, messages 3 | from django.utils.encoding import force_str 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.http import HttpRequest 6 | from django.db.models import QuerySet 7 | 8 | from .exceptions import APNSServerError, GCMError, WebPushError 9 | from .models import APNSDevice, GCMDevice, WebPushDevice, WNSDevice 10 | from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 11 | 12 | 13 | User = apps.get_model(*SETTINGS["USER_MODEL"].split(".")) 14 | 15 | 16 | class DeviceAdmin(admin.ModelAdmin): 17 | list_display = ("__str__", "device_id", "user", "active", "date_created") 18 | list_filter = ("active",) 19 | actions = ("send_message", "send_bulk_message", "enable", "disable") 20 | raw_id_fields = ("user",) 21 | 22 | if hasattr(User, "USERNAME_FIELD"): 23 | search_fields = ("name", "device_id", "user__%s" % (User.USERNAME_FIELD)) 24 | else: 25 | search_fields = ("name", "device_id", "") 26 | 27 | def send_messages(self, request: HttpRequest, queryset: QuerySet, bulk: bool = False) -> None: 28 | """ 29 | Provides error handling for DeviceAdmin send_message and send_bulk_message methods. 30 | """ 31 | ret = [] 32 | errors = [] 33 | r = "" 34 | 35 | for device in queryset: 36 | try: 37 | if bulk: 38 | r = queryset.send_message("Test bulk notification") 39 | else: 40 | r = device.send_message("Test single notification") 41 | if r: 42 | ret.append(r) 43 | except GCMError as e: 44 | errors.append(str(e)) 45 | except APNSServerError as e: 46 | errors.append(e.status) 47 | except WebPushError as e: 48 | errors.append(force_str(e)) 49 | 50 | if bulk: 51 | break 52 | 53 | # Because NotRegistered and InvalidRegistration do not throw GCMError 54 | # catch them here to display error msg. 55 | if not bulk: 56 | for r in ret: 57 | if "error" in r["results"][0]: 58 | errors.append(r["results"][0]["error"]) 59 | else: 60 | if "results" in ret[0][0]: 61 | try: 62 | errors = [r["error"] for r in ret[0][0]["results"] if "error" in r] 63 | except TypeError: 64 | for entry in ret[0][0]: 65 | errors = errors + [r["error"] for r in entry["results"] if "error" in r] 66 | except IndexError: 67 | pass 68 | else: 69 | # different format, e.g.: 70 | # [{'some_token1': 'Success', 71 | # 'some_token2': 'BadDeviceToken'}] 72 | for key, value in ret[0][0].items(): 73 | if value.lower() != "success": 74 | errors.append(value) 75 | if errors: 76 | self.message_user( 77 | request, _("Some messages could not be processed: %r" % (", ".join(errors))), 78 | level=messages.ERROR 79 | ) 80 | if ret: 81 | if bulk: 82 | # When the queryset exceeds the max_recipients value, the 83 | # send_message method returns a list of dicts, one per chunk 84 | if "results" in ret[0][0]: 85 | try: 86 | success = ret[0][0]["success"] 87 | except TypeError: 88 | success = 0 89 | for entry in ret[0][0]: 90 | success = success + entry["success"] 91 | if success == 0: 92 | return 93 | else: 94 | # different format, e.g.: 95 | # [{'some_token1': 'Success', 96 | # 'some_token2': 'BadDeviceToken'}] 97 | success = [] 98 | for key, value in ret[0][0].items(): 99 | if value.lower() == "success": 100 | success.append(key) 101 | 102 | elif len(errors) == len(ret): 103 | return 104 | if errors: 105 | msg = _("Some messages were sent: %s" % (ret)) 106 | else: 107 | msg = _("All messages were sent: %s" % (ret)) 108 | self.message_user(request, msg) 109 | 110 | def send_message(self, request: HttpRequest, queryset: QuerySet) -> None: 111 | self.send_messages(request, queryset) 112 | 113 | send_message.short_description = _("Send test message") 114 | 115 | def send_bulk_message(self, request: HttpRequest, queryset: QuerySet) -> None: 116 | self.send_messages(request, queryset, True) 117 | 118 | send_bulk_message.short_description = _("Send test message in bulk") 119 | 120 | def enable(self, request: HttpRequest, queryset: QuerySet) -> None: 121 | queryset.update(active=True) 122 | 123 | enable.short_description = _("Enable selected devices") 124 | 125 | def disable(self, request: HttpRequest, queryset: QuerySet) -> None: 126 | queryset.update(active=False) 127 | 128 | disable.short_description = _("Disable selected devices") 129 | 130 | 131 | class GCMDeviceAdmin(DeviceAdmin): 132 | list_display = ( 133 | "__str__", "device_id", "user", "active", "date_created", "cloud_message_type" 134 | ) 135 | list_filter = ("active", "cloud_message_type") 136 | 137 | def send_messages(self, request: HttpRequest, queryset: QuerySet, bulk: bool = False) -> None: 138 | """ 139 | Provides error handling for DeviceAdmin send_message and send_bulk_message methods. 140 | """ 141 | results = [] 142 | errors = [] 143 | 144 | if bulk: 145 | results.append(queryset.send_message("Test bulk notification")) 146 | else: 147 | for device in queryset: 148 | result = device.send_message("Test single notification") 149 | if result: 150 | results.append(result) 151 | 152 | for batch in results: 153 | for response in batch.responses: 154 | if response.exception: 155 | errors.append(repr(response.exception)) 156 | 157 | if errors: 158 | self.message_user( 159 | request, _("Some messages could not be processed: %s") % (", ".join(errors)), 160 | level=messages.ERROR 161 | ) 162 | else: 163 | self.message_user( 164 | request, _("All messages were sent."), 165 | level=messages.SUCCESS 166 | ) 167 | 168 | 169 | class WebPushDeviceAdmin(DeviceAdmin): 170 | list_display = ("__str__", "browser", "user", "active", "date_created") 171 | list_filter = ("active", "browser") 172 | 173 | if hasattr(User, "USERNAME_FIELD"): 174 | search_fields = ("name", "registration_id", "user__%s" % (User.USERNAME_FIELD)) 175 | else: 176 | search_fields = ("name", "registration_id", "") 177 | 178 | 179 | admin.site.register(APNSDevice, DeviceAdmin) 180 | admin.site.register(GCMDevice, GCMDeviceAdmin) 181 | admin.site.register(WNSDevice, DeviceAdmin) 182 | admin.site.register(WebPushDevice, WebPushDeviceAdmin) 183 | -------------------------------------------------------------------------------- /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 | from typing import Optional, Dict, Any, List, Union 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 APNSUnsupportedPriority, APNSServerError 17 | 18 | 19 | def _apns_create_socket(creds: Optional[apns2_credentials.Credentials] = None, application_id: Optional[str] = None) -> apns2_client.APNsClient: 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: str, 43 | alert: Optional[str], 44 | application_id: Optional[str] = None, 45 | badge: Optional[int] = None, 46 | sound: Optional[str] = None, 47 | category: Optional[str] = None, 48 | content_available: bool = False, 49 | action_loc_key: Optional[str] = None, 50 | loc_key: Optional[str] = None, 51 | loc_args: List[Any] = [], 52 | extra: Dict[str, Any] = {}, 53 | mutable_content: bool = False, 54 | thread_id: Optional[str] = None, 55 | url_args: Optional[list] = None 56 | ) -> apns2_payload.Payload: 57 | if action_loc_key or loc_key or loc_args: 58 | apns2_alert = apns2_payload.PayloadAlert( 59 | body=alert if alert else {}, body_localized_key=loc_key, 60 | body_localized_args=loc_args, action_localized_key=action_loc_key) 61 | else: 62 | apns2_alert = alert 63 | 64 | if callable(badge): 65 | badge = badge(token) 66 | 67 | return apns2_payload.Payload( 68 | alert=apns2_alert, badge=badge, sound=sound, category=category, 69 | url_args=url_args, custom=extra, thread_id=thread_id, 70 | content_available=content_available, mutable_content=mutable_content) 71 | 72 | 73 | def _apns_send( 74 | registration_id: Union[str, List[str]], 75 | alert: Optional[str] = None, 76 | batch: bool = False, 77 | application_id: Optional[str] = None, 78 | creds: Optional[apns2_credentials.Credentials] = None, 79 | **kwargs: Any 80 | ) -> Optional[Dict[str, str]]: 81 | client = _apns_create_socket(creds=creds, application_id=application_id) 82 | 83 | notification_kwargs: Dict[str, Any] = {} 84 | 85 | # if expiration isn"t specified use 1 month from now 86 | notification_kwargs["expiration"] = kwargs.pop("expiration", None) 87 | if not notification_kwargs["expiration"]: 88 | notification_kwargs["expiration"] = int(time.time()) + 2592000 89 | 90 | priority = kwargs.pop("priority", None) 91 | if priority: 92 | try: 93 | notification_kwargs["priority"] = apns2_client.NotificationPriority(str(priority)) 94 | except ValueError: 95 | raise APNSUnsupportedPriority("Unsupported priority %d" % (priority)) 96 | 97 | notification_kwargs["collapse_id"] = kwargs.pop("collapse_id", None) 98 | 99 | if batch: 100 | data = [apns2_client.Notification( 101 | token=rid, payload=_apns_prepare(rid, alert, **kwargs)) for rid in registration_id] 102 | # returns a dictionary mapping each token to its result. That 103 | # result is either "Success" or the reason for the failure. 104 | return client.send_notification_batch( 105 | data, get_manager().get_apns_topic(application_id=application_id), 106 | **notification_kwargs 107 | ) 108 | 109 | data = _apns_prepare(registration_id, alert, **kwargs) 110 | client.send_notification( 111 | registration_id, data, 112 | get_manager().get_apns_topic(application_id=application_id), 113 | **notification_kwargs 114 | ) 115 | 116 | 117 | def apns_send_message( 118 | registration_id: str, 119 | alert: Optional[str] = None, 120 | application_id: Optional[str] = None, 121 | creds: Optional[apns2_credentials.Credentials] = None, 122 | **kwargs: Any 123 | ) -> None: 124 | """ 125 | Sends an APNS notification to a single registration_id. 126 | This will send the notification as form data. 127 | If sending multiple notifications, it is more efficient to use 128 | apns_send_bulk_message() 129 | 130 | Note that if set alert should always be a string. If it is not set, 131 | it won"t be included in the notification. You will need to pass None 132 | to this for silent notifications. 133 | """ 134 | 135 | try: 136 | _apns_send( 137 | registration_id, alert, application_id=application_id, 138 | creds=creds, **kwargs 139 | ) 140 | except apns2_errors.APNsException as apns2_exception: 141 | if isinstance(apns2_exception, apns2_errors.Unregistered): 142 | models.APNSDevice.objects.filter(registration_id=registration_id).update(active=False) 143 | 144 | raise APNSServerError(status=apns2_exception.__class__.__name__) 145 | 146 | 147 | def apns_send_bulk_message( 148 | registration_ids: List[str], 149 | alert: Optional[str] = None, 150 | application_id: Optional[str] = None, 151 | creds: Optional[apns2_credentials.Credentials] = None, 152 | **kwargs: Any 153 | ) -> Optional[Dict[str, str]]: 154 | """ 155 | Sends an APNS notification to one or more registration_ids. 156 | The registration_ids argument needs to be a list. 157 | 158 | Note that if set alert should always be a string. If it is not set, 159 | it won"t be included in the notification. You will need to pass None 160 | to this for silent notifications. 161 | """ 162 | 163 | results = _apns_send( 164 | registration_ids, alert, batch=True, application_id=application_id, 165 | creds=creds, **kwargs 166 | ) 167 | inactive_tokens = [token for token, result in results.items() if result == "Unregistered"] 168 | models.APNSDevice.objects.filter(registration_id__in=inactive_tokens).update(active=False) 169 | return results 170 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, Dict, Any, Generator, Optional 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[str, Any], dry_run: bool = False, **kwargs: Any) -> 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(lst: List[Any], n: int) -> Generator[List[Any], None, None]: 91 | """ 92 | Yield successive chunks from list \a lst with a maximum size \a n 93 | """ 94 | for i in range(0, len(lst), n): 95 | yield lst[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 | if isinstance(exc, str): 112 | return exc in fcm_error_list_str 113 | return ( 114 | isinstance(exc, InvalidArgumentError) and exc.cause == "Invalid registration" 115 | ) or (type(exc) in fcm_error_list) 116 | 117 | 118 | def _deactivate_devices_with_error_results( 119 | registration_ids: List[str], 120 | results: List[Union[messaging.SendResponse, messaging.ErrorInfo]], 121 | ) -> List[str]: 122 | if not results: 123 | return [] 124 | if isinstance(results[0], messaging.SendResponse): 125 | deactivated_ids = [ 126 | token 127 | for item, token in zip(results, registration_ids) 128 | if _validate_exception_for_deactivation(item.exception) 129 | ] 130 | else: 131 | deactivated_ids = [ 132 | registration_ids[x.index] 133 | for x in results 134 | if _validate_exception_for_deactivation(x.reason) 135 | ] 136 | from .models import GCMDevice 137 | GCMDevice.objects.filter(registration_id__in=deactivated_ids).update(active=False) 138 | return deactivated_ids 139 | 140 | 141 | def _prepare_message(message: messaging.Message, token: str) -> messaging.Message: 142 | message.token = token 143 | return copy(message) 144 | 145 | 146 | def send_message( 147 | registration_ids: Union[List[str], str, None], 148 | message: messaging.Message, 149 | application_id: Optional[str] = None, 150 | dry_run: bool = False, 151 | **kwargs: Any 152 | ) -> Optional[messaging.BatchResponse]: 153 | """ 154 | Sends an FCM notification to one or more registration_ids. The registration_ids 155 | can be a list or a single string. 156 | 157 | :param registration_ids: A list of registration ids or a single string 158 | :param message: The Message object, use `dict_to_fcm_message` to convert dict to Message 159 | :param application_id: The application id to use. 160 | :param dry_run: If True, no message will be sent. 161 | 162 | :return: A BatchResponse object 163 | """ 164 | max_recipients = get_manager().get_max_recipients(application_id) 165 | app = get_manager().get_firebase_app(application_id) if application_id else None 166 | 167 | # Checks for valid recipient 168 | if registration_ids is None and message.topic is None and message.condition is None: 169 | return 170 | 171 | # Bundles the registration_ids in an list if only one is sent 172 | if not isinstance(registration_ids, list): 173 | registration_ids = [registration_ids] if registration_ids else None 174 | 175 | # FCM only allows up to 1000 reg ids per bulk message 176 | # https://firebase.google.com/docs/cloud-messaging/server#http-request 177 | if registration_ids: 178 | ret: List[messaging.SendResponse] = [] 179 | for chunk in _chunks(registration_ids, max_recipients): 180 | messages = [ 181 | _prepare_message(message, token) for token in chunk 182 | ] 183 | responses = messaging.send_each(messages, dry_run=dry_run, app=app).responses 184 | ret.extend(responses) 185 | _deactivate_devices_with_error_results(registration_ids, ret) 186 | return messaging.BatchResponse(ret) 187 | else: 188 | return messaging.BatchResponse([]) 189 | 190 | 191 | send_bulk_message = send_message 192 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/api/rest_framework.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions, status 2 | from rest_framework.fields import IntegerField 3 | from rest_framework.response import Response 4 | from rest_framework.serializers import ModelSerializer, Serializer, ValidationError 5 | from rest_framework.viewsets import ModelViewSet 6 | 7 | from push_notifications.fields import UNSIGNED_64BIT_INT_MAX_VALUE, hex_re 8 | from push_notifications.models import APNSDevice, GCMDevice, WebPushDevice, WNSDevice 9 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 10 | from typing import Any, Union, Dict, Optional 11 | 12 | 13 | # Fields 14 | class HexIntegerField(IntegerField): 15 | """ 16 | Store an integer represented as a hex string of form "0x01". 17 | """ 18 | 19 | def to_internal_value(self, data: Union[str, int]) -> int: 20 | # validate hex string and convert it to the unsigned 21 | # integer representation for internal use 22 | try: 23 | data = int(data, 16) if not isinstance(data, int) else data 24 | except ValueError: 25 | raise ValidationError("Device ID is not a valid hex number") 26 | return super().to_internal_value(data) 27 | 28 | def to_representation(self, value: int) -> int: 29 | return value 30 | 31 | 32 | # Serializers 33 | class DeviceSerializerMixin(ModelSerializer): 34 | class Meta: 35 | fields = ( 36 | "id", 37 | "name", 38 | "application_id", 39 | "registration_id", 40 | "device_id", 41 | "active", 42 | "date_created", 43 | ) 44 | read_only_fields = ("date_created",) 45 | 46 | # See https://github.com/tomchristie/django-rest-framework/issues/1101 47 | extra_kwargs = {"active": {"default": True}} 48 | 49 | 50 | class APNSDeviceSerializer(ModelSerializer): 51 | class Meta(DeviceSerializerMixin.Meta): 52 | model = APNSDevice 53 | 54 | def validate_registration_id(self, value: str) -> str: 55 | # https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622958-application 56 | # As of 02/2023 APNS tokens (registration_id) "are of variable length. Do not hard-code their size." 57 | if hex_re.match(value) is None: 58 | raise ValidationError("Registration ID (device token) is invalid") 59 | 60 | return value 61 | 62 | 63 | class UniqueRegistrationSerializerMixin(Serializer): 64 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 65 | devices: Optional[Any] = None 66 | primary_key: Optional[Any] = None 67 | request_method: Optional[str] = None 68 | 69 | if self.initial_data.get("registration_id", None): 70 | if self.instance: 71 | request_method = "update" 72 | primary_key = self.instance.id 73 | else: 74 | request_method = "create" 75 | else: 76 | if self.context["request"].method in ["PUT", "PATCH"]: 77 | request_method = "update" 78 | primary_key = self.instance.id 79 | elif self.context["request"].method == "POST": 80 | request_method = "create" 81 | 82 | Device = self.Meta.model 83 | if request_method == "update": 84 | reg_id: str = attrs.get("registration_id", self.instance.registration_id) 85 | devices = Device.objects.filter(registration_id=reg_id).exclude( 86 | id=primary_key 87 | ) 88 | elif request_method == "create": 89 | devices = Device.objects.filter(registration_id=attrs["registration_id"]) 90 | 91 | if devices: 92 | raise ValidationError({"registration_id": "This field must be unique."}) 93 | return attrs 94 | 95 | 96 | class GCMDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer): 97 | device_id = HexIntegerField( 98 | help_text="ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)", 99 | style={"input_type": "text"}, 100 | required=False, 101 | allow_null=True, 102 | ) 103 | 104 | class Meta(DeviceSerializerMixin.Meta): 105 | model = GCMDevice 106 | fields = ( 107 | "id", 108 | "name", 109 | "registration_id", 110 | "device_id", 111 | "active", 112 | "date_created", 113 | "cloud_message_type", 114 | "application_id", 115 | ) 116 | extra_kwargs = {"id": {"read_only": False, "required": False}} 117 | 118 | def validate_device_id(self, value: Optional[int] = None) -> Optional[int]: 119 | # device ids are 64 bit unsigned values 120 | if value is not None and value > UNSIGNED_64BIT_INT_MAX_VALUE: 121 | raise ValidationError("Device ID is out of range") 122 | return value 123 | 124 | 125 | class WNSDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer): 126 | class Meta(DeviceSerializerMixin.Meta): 127 | model = WNSDevice 128 | 129 | 130 | class WebPushDeviceSerializer(UniqueRegistrationSerializerMixin, ModelSerializer): 131 | class Meta(DeviceSerializerMixin.Meta): 132 | model = WebPushDevice 133 | fields = ( 134 | "id", 135 | "name", 136 | "registration_id", 137 | "active", 138 | "date_created", 139 | "p256dh", 140 | "auth", 141 | "browser", 142 | "application_id", 143 | ) 144 | 145 | 146 | # Permissions 147 | class IsOwner(permissions.BasePermission): 148 | def has_object_permission(self, request: Any, view: Any, obj: Any) -> bool: 149 | # must be the owner to view the object 150 | return obj.user == request.user 151 | 152 | 153 | # Mixins 154 | class DeviceViewSetMixin: 155 | lookup_field: str = "registration_id" 156 | 157 | def create(self, request: Any, *args: Any, **kwargs: Any) -> Response: 158 | serializer: Optional[Any] = None 159 | is_update: bool = False 160 | if ( 161 | SETTINGS.get("UPDATE_ON_DUPLICATE_REG_ID") 162 | and self.lookup_field in request.data 163 | ): 164 | instance = self.queryset.model.objects.filter( 165 | registration_id=request.data[self.lookup_field] 166 | ).first() 167 | if instance: 168 | serializer = self.get_serializer(instance, data=request.data) 169 | is_update = True 170 | if not serializer: 171 | serializer = self.get_serializer(data=request.data) 172 | 173 | serializer.is_valid(raise_exception=True) 174 | if is_update: 175 | self.perform_update(serializer) 176 | return Response(serializer.data) 177 | else: 178 | self.perform_create(serializer) 179 | headers = self.get_success_headers(serializer.data) 180 | return Response( 181 | serializer.data, status=status.HTTP_201_CREATED, headers=headers 182 | ) 183 | 184 | def perform_create(self, serializer: Serializer) -> Any: 185 | if self.request.user.is_authenticated: 186 | serializer.save(user=self.request.user) 187 | return super().perform_create(serializer) 188 | 189 | def perform_update(self, serializer: Serializer) -> Any: 190 | if self.request.user.is_authenticated: 191 | serializer.save(user=self.request.user) 192 | return super().perform_update(serializer) 193 | 194 | 195 | class AuthorizedMixin: 196 | permission_classes: tuple = (permissions.IsAuthenticated, IsOwner) 197 | 198 | def get_queryset(self) -> Any: 199 | # filter all devices to only those belonging to the current user 200 | return self.queryset.filter(user=self.request.user) 201 | 202 | 203 | # ViewSets 204 | class APNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 205 | queryset = APNSDevice.objects.all() 206 | serializer_class = APNSDeviceSerializer 207 | 208 | 209 | class APNSDeviceAuthorizedViewSet(AuthorizedMixin, APNSDeviceViewSet): 210 | pass 211 | 212 | 213 | class GCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 214 | queryset = GCMDevice.objects.all() 215 | serializer_class = GCMDeviceSerializer 216 | 217 | 218 | class GCMDeviceAuthorizedViewSet(AuthorizedMixin, GCMDeviceViewSet): 219 | pass 220 | 221 | 222 | class WNSDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 223 | queryset = WNSDevice.objects.all() 224 | serializer_class = WNSDeviceSerializer 225 | 226 | 227 | class WNSDeviceAuthorizedViewSet(AuthorizedMixin, WNSDeviceViewSet): 228 | pass 229 | 230 | 231 | class WebPushDeviceViewSet(DeviceViewSetMixin, ModelViewSet): 232 | queryset = WebPushDevice.objects.all() 233 | serializer_class = WebPushDeviceSerializer 234 | lookup_value_regex: str = ".+" 235 | 236 | 237 | class WebPushDeviceAuthorizedViewSet(AuthorizedMixin, WebPushDeviceViewSet): 238 | pass 239 | -------------------------------------------------------------------------------- /push_notifications/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from typing import List, Optional, Dict, Any 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 | ("SAFARI", "Safari") 19 | ) 20 | 21 | 22 | class Device(models.Model): 23 | name = models.CharField(max_length=255, verbose_name=_("Name"), blank=True, null=True) 24 | active = models.BooleanField( 25 | verbose_name=_("Is active"), default=True, 26 | help_text=_("Inactive devices will not be sent notifications") 27 | ) 28 | user = models.ForeignKey( 29 | SETTINGS["USER_MODEL"], blank=True, null=True, on_delete=models.CASCADE 30 | ) 31 | date_created = models.DateTimeField( 32 | verbose_name=_("Creation date"), auto_now_add=True, null=True 33 | ) 34 | application_id = models.CharField( 35 | max_length=64, verbose_name=_("Application ID"), 36 | help_text=_( 37 | "Opaque application identity, should be filled in for multiple" 38 | " key/certificate access" 39 | ), 40 | blank=True, null=True 41 | ) 42 | 43 | class Meta: 44 | abstract = True 45 | 46 | def __str__(self) -> str: 47 | return ( 48 | self.name or 49 | str(self.device_id or "") or 50 | "{} for {}".format(self.__class__.__name__, self.user or "unknown user") 51 | ) 52 | 53 | 54 | class GCMDeviceManager(models.Manager): 55 | def get_queryset(self) -> "GCMDeviceQuerySet": 56 | return GCMDeviceQuerySet(self.model) 57 | 58 | 59 | class GCMDeviceQuerySet(models.query.QuerySet): 60 | def send_message(self, message: Any, **kwargs: Any) -> Any: 61 | if self.exists(): 62 | from .gcm import dict_to_fcm_message, messaging 63 | from .gcm import send_message as fcm_send_message 64 | 65 | if not isinstance(message, messaging.Message): 66 | data = kwargs.pop("extra", {}) 67 | if message is not None: 68 | data["message"] = message 69 | # transform legacy data to new message object 70 | message = dict_to_fcm_message(data, **kwargs) 71 | 72 | app_ids = list(self.filter(active=True).order_by( 73 | "application_id" 74 | ).values_list("application_id", flat=True).distinct()) 75 | 76 | responses = [] 77 | for app_id in app_ids: 78 | reg_ids = list( 79 | self.filter( 80 | active=True, cloud_message_type="FCM", application_id=app_id).values_list( 81 | "registration_id", flat=True 82 | ) 83 | ) 84 | if reg_ids: 85 | r = fcm_send_message(reg_ids, message, application_id=app_id, **kwargs) 86 | responses.extend(r.responses) 87 | 88 | return messaging.BatchResponse(responses) 89 | 90 | 91 | class GCMDevice(Device): 92 | # device_id cannot be a reliable primary key as fragmentation between different devices 93 | # can make it turn out to be null and such: 94 | # http://android-developers.blogspot.co.uk/2011/03/identifying-app-installations.html 95 | device_id = HexIntegerField( 96 | verbose_name=_("Device ID"), blank=True, null=True, db_index=True, 97 | help_text=_("ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)") 98 | ) 99 | registration_id = models.TextField(verbose_name=_("Registration ID"), unique=SETTINGS["UNIQUE_REG_ID"]) 100 | cloud_message_type = models.CharField( 101 | verbose_name=_("Cloud Message Type"), max_length=3, 102 | choices=CLOUD_MESSAGE_TYPES, default="FCM", 103 | help_text=_("You should choose FCM, GCM is deprecated") 104 | ) 105 | objects = GCMDeviceManager() 106 | 107 | class Meta: 108 | verbose_name = _("FCM device") 109 | 110 | def send_message(self, message: Any, **kwargs: Any) -> Optional[Any]: 111 | from .gcm import dict_to_fcm_message, messaging 112 | from .gcm import send_message as fcm_send_message 113 | 114 | # GCM is not supported. 115 | if self.cloud_message_type == "GCM": 116 | return 117 | 118 | if not isinstance(message, messaging.Message): 119 | data: Dict[str, Any] = kwargs.pop("extra", {}) 120 | if message is not None: 121 | data["message"] = message 122 | # transform legacy data to new message object 123 | message = dict_to_fcm_message(data, **kwargs) 124 | 125 | return fcm_send_message( 126 | self.registration_id, message, 127 | application_id=self.application_id, **kwargs 128 | ) 129 | 130 | class APNSDeviceManager(models.Manager): 131 | def get_queryset(self) -> "APNSDeviceQuerySet": 132 | return APNSDeviceQuerySet(self.model) 133 | 134 | 135 | class APNSDeviceQuerySet(models.query.QuerySet): 136 | def send_message(self, message: Any, creds: Optional[Any] = None, **kwargs: Any) -> List[Any]: 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: Any, creds: Optional[Any] = None, **kwargs: Any) -> Any: 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) -> "WNSDeviceQuerySet": 193 | return WNSDeviceQuerySet(self.model) 194 | 195 | 196 | class WNSDeviceQuerySet(models.query.QuerySet): 197 | def send_message(self, message: Any, **kwargs: Any) -> List[Any]: 198 | from .wns import wns_send_bulk_message 199 | 200 | app_ids = list(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 = list(self.filter(active=True, application_id=app_id).values_list( 206 | "registration_id", flat=True 207 | )) 208 | r = wns_send_bulk_message(uri_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: Any, **kwargs: Any) -> str: 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) -> "WebPushDeviceQuerySet": 240 | return WebPushDeviceQuerySet(self.model) 241 | 242 | 243 | class WebPushDeviceQuerySet(models.query.QuerySet): 244 | def send_message(self, message: Any, **kwargs: Any) -> List[Any]: 245 | devices = self.filter(active=True).order_by("application_id").distinct() 246 | res: List[Any] = [] 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, Safari and Opera browsers") 265 | ) 266 | objects = WebPushDeviceManager() 267 | 268 | class Meta: 269 | verbose_name = _("WebPush device") 270 | 271 | @property 272 | def device_id(self) -> None: 273 | return None 274 | 275 | def send_message(self, message: Any, **kwargs: Any) -> Any: 276 | from .webpush import webpush_send_message 277 | 278 | return webpush_send_message(self, message, **kwargs) 279 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | from typing import Dict, List, Optional, Any 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( 32 | scope: str = "notify.windows.com", application_id: Optional[str] = None 33 | ) -> str: 34 | """ 35 | Requests an Access token for WNS communication. 36 | 37 | :return: dict: {'access_token': , 'expires_in': , 'token_type': 'bearer'} 38 | """ 39 | client_id = get_manager().get_wns_package_security_id(application_id) 40 | client_secret = get_manager().get_wns_secret_key(application_id) 41 | if not client_id: 42 | raise ImproperlyConfigured( 43 | 'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_PACKAGE_SECURITY_ID"] to use WNS.' 44 | ) 45 | 46 | if not client_secret: 47 | raise ImproperlyConfigured( 48 | 'You need to set PUSH_NOTIFICATIONS_SETTINGS["WNS_SECRET_KEY"] to use WNS.' 49 | ) 50 | 51 | headers = { 52 | "Content-Type": "application/x-www-form-urlencoded", 53 | } 54 | params = { 55 | "grant_type": "client_credentials", 56 | "client_id": client_id, 57 | "client_secret": client_secret, 58 | "scope": scope, 59 | } 60 | data = urlencode(params).encode("utf-8") 61 | 62 | request = Request(SETTINGS["WNS_ACCESS_URL"], data=data, headers=headers) 63 | try: 64 | response = urlopen(request) 65 | except HTTPError as err: 66 | if err.code == 400: 67 | # One of your settings is probably jacked up. 68 | # https://msdn.microsoft.com/en-us/library/windows/apps/xaml/hh868245 69 | raise WNSAuthenticationError( 70 | "Authentication failed, check your WNS settings." 71 | ) 72 | raise err 73 | 74 | oauth_data = response.read().decode("utf-8") 75 | try: 76 | oauth_data = json.loads(oauth_data) 77 | except Exception: 78 | # Upstream WNS issue 79 | raise WNSAuthenticationError("Received invalid JSON data from WNS.") 80 | 81 | access_token = oauth_data.get("access_token") 82 | if not access_token: 83 | # Upstream WNS issue 84 | raise WNSAuthenticationError("Access token missing from WNS response.") 85 | 86 | return access_token 87 | 88 | 89 | def _wns_send( 90 | uri: str, 91 | data: bytes, 92 | wns_type: str = "wns/toast", 93 | application_id: Optional[str] = None, 94 | ) -> Any: 95 | """ 96 | Sends a notification data and authentication to WNS. 97 | 98 | :param uri: str: The device's unique notification URI 99 | :param data: dict: The notification data to be sent. 100 | :return: 101 | """ 102 | access_token = _wns_authenticate(application_id=application_id) 103 | 104 | content_type = "text/xml" 105 | if wns_type == "wns/raw": 106 | content_type = "application/octet-stream" 107 | 108 | headers = { 109 | # content_type is "text/xml" (toast/badge/tile) | "application/octet-stream" (raw) 110 | "Content-Type": content_type, 111 | "Authorization": "Bearer %s" % (access_token), 112 | "X-WNS-Type": wns_type, # wns/toast | wns/badge | wns/tile | wns/raw 113 | } 114 | 115 | if isinstance(data, str): 116 | data = data.encode("utf-8") 117 | 118 | request = Request(uri, data, headers) 119 | 120 | # A lot of things can happen, let them know which one. 121 | try: 122 | response = urlopen(request) 123 | except HTTPError as err: 124 | if err.code == 400: 125 | msg = "One or more headers were specified incorrectly or conflict with another header." 126 | elif err.code == 401: 127 | msg = "The cloud service did not present a valid authentication ticket." 128 | elif err.code == 403: 129 | msg = "The cloud service is not authorized to send a notification to this URI." 130 | elif err.code == 404: 131 | msg = "The channel URI is not valid or is not recognized by WNS." 132 | elif err.code == 405: 133 | msg = "Invalid method. Only POST or DELETE is allowed." 134 | elif err.code == 406: 135 | msg = "The cloud service exceeded its throttle limit" 136 | elif err.code == 410: 137 | msg = "The channel expired." 138 | elif err.code == 413: 139 | msg = "The notification payload exceeds the 500 byte limit." 140 | elif err.code == 500: 141 | msg = "An internal failure caused notification delivery to fail." 142 | elif err.code == 503: 143 | msg = "The server is currently unavailable." 144 | else: 145 | raise err 146 | raise WNSNotificationResponseError("HTTP %i: %s" % (err.code, msg)) 147 | 148 | return response.read().decode("utf-8") 149 | 150 | 151 | def _wns_prepare_toast(data: Dict[str, List[str]], **kwargs: Any) -> bytes: 152 | """ 153 | Creates the xml tree for a `toast` notification 154 | 155 | :param data: dict: The notification data to be converted to an xml tree. 156 | 157 | { 158 | "text": ["Title text", "Message Text", "Another message!"], 159 | "image": ["src1", "src2"], 160 | } 161 | 162 | :return: str 163 | """ 164 | root = ET.Element("toast") 165 | visual = ET.SubElement(root, "visual") 166 | binding = ET.SubElement(visual, "binding") 167 | binding.attrib["template"] = kwargs.pop("template", "ToastText01") 168 | if "text" in data: 169 | for count, item in enumerate(data["text"], start=1): 170 | elem = ET.SubElement(binding, "text") 171 | elem.text = item 172 | elem.attrib["id"] = str(count) 173 | if "image" in data: 174 | for count, item in enumerate(data["image"], start=1): 175 | elem = ET.SubElement(binding, "img") 176 | elem.attrib["src"] = item 177 | elem.attrib["id"] = str(count) 178 | return ET.tostring(root) 179 | 180 | 181 | def wns_send_message( 182 | uri: str, 183 | message: Optional[Any] = None, 184 | xml_data: Optional[Dict[str, Any]] = None, 185 | raw_data: Optional[str] = None, 186 | application_id: Optional[str] = None, 187 | **kwargs: Any, 188 | ) -> str: 189 | """ 190 | Sends a notification request to WNS. 191 | There are four notification types that WNS can send: toast, tile, badge and raw. 192 | Toast, tile, and badge can all be customized to use different 193 | templates/icons/sounds/launch params/etc. 194 | See docs for more information: 195 | https://msdn.microsoft.com/en-us/library/windows/apps/br212853.aspx 196 | 197 | There are multiple ways to input notification data: 198 | 199 | 1. The simplest and least custom notification to send is to just pass a string 200 | to `message`. This will create a toast notification with one text element. e.g.: 201 | "This is my notification title" 202 | 203 | 2. You can also pass a dictionary to `message`: it can only contain one or both 204 | keys: ["text", "image"]. The value of each key must be a list with the text and 205 | src respectively. e.g.: 206 | { 207 | "text": ["text1", "text2"], 208 | "image": ["src1", "src2"], 209 | } 210 | 211 | 3. Passing a dictionary to `xml_data` will create one of three types of 212 | notifications depending on the dictionary data (toast, tile, badge). 213 | See `dict_to_xml_schema` docs for more information on dictionary formatting. 214 | 215 | 4. Passing a value to `raw_data` will create a `raw` notification and send the 216 | input data as is. 217 | 218 | :param uri: str: The device's unique notification uri. 219 | :param message: str|dict: The notification data to be sent. 220 | :param xml_data: dict: A dictionary containing data to be converted to an xml tree. 221 | :param raw_data: str: Data to be sent via a `raw` notification. 222 | """ 223 | # Create a simple toast notification 224 | if message: 225 | wns_type = "wns/toast" 226 | if isinstance(message, str): 227 | message = { 228 | "text": [ 229 | message, 230 | ], 231 | } 232 | prepared_data = _wns_prepare_toast(data=message, **kwargs) 233 | # Create a toast/tile/badge notification from a dictionary 234 | elif xml_data: 235 | xml = dict_to_xml_schema(xml_data) 236 | wns_type = "wns/%s" % xml.tag 237 | prepared_data = ET.tostring(xml) 238 | # Create a raw notification 239 | elif raw_data: 240 | wns_type = "wns/raw" 241 | prepared_data = raw_data 242 | else: 243 | raise TypeError( 244 | "At least one of the following parameters must be set:" 245 | "`message`, `xml_data`, `raw_data`" 246 | ) 247 | 248 | return _wns_send( 249 | uri=uri, data=prepared_data, wns_type=wns_type, application_id=application_id 250 | ) 251 | 252 | 253 | def wns_send_bulk_message( 254 | uri_list: List[str], 255 | message: Optional[Any] = None, 256 | xml_data: Optional[Dict[str, Any]] = None, 257 | raw_data: Optional[str] = None, 258 | application_id: Optional[str] = None, 259 | **kwargs: Any, 260 | ) -> List[str]: 261 | """ 262 | WNS doesn't support bulk notification, so we loop through each uri. 263 | 264 | :param uri_list: list: A list of uris the notification will be sent to. 265 | :param message: str: The notification data to be sent. 266 | :param xml_data: dict: A dictionary containing data to be converted to an xml tree. 267 | :param raw_data: str: Data to be sent via a `raw` notification. 268 | """ 269 | res = [] 270 | if uri_list: 271 | for uri in uri_list: 272 | r = wns_send_message( 273 | uri=uri, 274 | message=message, 275 | xml_data=xml_data, 276 | raw_data=raw_data, 277 | application_id=application_id, 278 | **kwargs, 279 | ) 280 | res.append(r) 281 | return res 282 | 283 | 284 | def dict_to_xml_schema(data: Dict[str, Any]) -> ET.Element: 285 | """ 286 | Input a dictionary to be converted to xml. There should be only one key at 287 | the top level. The value must be a dict with (required) `children` key and 288 | (optional) `attrs` key. This will be called the `sub-element dictionary`. 289 | 290 | The `attrs` value must be a dictionary; each value will be added to the 291 | element's xml tag as attributes. e.g.: 292 | {"example": { 293 | "attrs": { 294 | "key1": "value1", 295 | ... 296 | }, 297 | ... 298 | }} 299 | 300 | would result in: 301 | 302 | 303 | If the value is a dict it must contain one or more keys which will be used 304 | as the sub-element names. Each sub-element must have a value of a sub-element 305 | dictionary(see above) or a list of sub-element dictionaries. 306 | If the value is not a dict, it will be the value of the element. 307 | If the value is a list, multiple elements of the same tag will be created 308 | from each sub-element dict in the list. 309 | 310 | :param data: dict: Used to create an XML tree. e.g.: 311 | example_data = { 312 | "toast": { 313 | "attrs": { 314 | "launch": "param", 315 | "duration": "short", 316 | }, 317 | "children": { 318 | "visual": { 319 | "children": { 320 | "binding": { 321 | "attrs": {"template": "ToastText01"}, 322 | "children": { 323 | "text": [ 324 | { 325 | "attrs": {"id": "1"}, 326 | "children": "text1", 327 | }, 328 | { 329 | "attrs": {"id": "2"}, 330 | "children": "text2", 331 | }, 332 | ], 333 | }, 334 | }, 335 | }, 336 | }, 337 | }, 338 | }, 339 | } 340 | :return: ElementTree.Element 341 | """ 342 | for key, value in data.items(): 343 | root = _add_element_attrs(ET.Element(key), value.get("attrs", {})) 344 | children = value.get("children", None) 345 | if isinstance(children, dict): 346 | _add_sub_elements_from_dict(root, children) 347 | return root 348 | 349 | 350 | def _add_sub_elements_from_dict(parent: ET.Element, sub_dict: Dict[str, Any]) -> None: 351 | """ 352 | Add SubElements to the parent element. 353 | 354 | :param parent: ElementTree.Element: The parent element for the newly created SubElement. 355 | :param sub_dict: dict: Used to create a new SubElement. See `dict_to_xml_schema` 356 | method docstring for more information. e.g.: 357 | {"example": { 358 | "attrs": { 359 | "key1": "value1", 360 | ... 361 | }, 362 | ... 363 | }} 364 | """ 365 | for key, value in sub_dict.items(): 366 | if isinstance(value, list): 367 | for repeated_element in value: 368 | sub_element = ET.SubElement(parent, key) 369 | _add_element_attrs(sub_element, repeated_element.get("attrs", {})) 370 | children = repeated_element.get("children", None) 371 | if isinstance(children, dict): 372 | _add_sub_elements_from_dict(sub_element, children) 373 | elif isinstance(children, str): 374 | sub_element.text = children 375 | else: 376 | sub_element = ET.SubElement(parent, key) 377 | _add_element_attrs(sub_element, value.get("attrs", {})) 378 | children = value.get("children", None) 379 | if isinstance(children, dict): 380 | _add_sub_elements_from_dict(sub_element, children) 381 | elif isinstance(children, str): 382 | sub_element.text = children 383 | 384 | 385 | def _add_element_attrs(elem: ET.Element, attrs: Dict[str, str]) -> ET.Element: 386 | """ 387 | Add attributes to the given element. 388 | 389 | :param elem: ElementTree.Element: The element the attributes are being added to. 390 | :param attrs: dict: A dictionary of attributes. e.g.: 391 | {"attribute1": "value", "attribute2": "another"} 392 | :return: ElementTree.Element 393 | """ 394 | for attr, value in attrs.items(): 395 | elem.attrib[attr] = value 396 | return elem 397 | -------------------------------------------------------------------------------- /push_notifications/conf/app.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 4 | from .base import BaseConfig, check_apns_certificate 5 | from typing import Dict, List, Any, Optional, Tuple 6 | 7 | 8 | SETTING_MISMATCH = "Application '{application_id}' ({platform}) does not support the setting '{setting}'." 9 | 10 | # code can be "missing" or "invalid" 11 | BAD_PLATFORM = ( 12 | 'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["PLATFORM"] is {code}. ' 13 | "Must be one of: {platforms}." 14 | ) 15 | 16 | UNKNOWN_PLATFORM = "Unknown Platform: {platform}. Must be one of: {platforms}." 17 | 18 | MISSING_SETTING = 'PUSH_NOTIFICATIONS_SETTINGS.APPLICATIONS["{application_id}"]["{setting}"] is missing.' 19 | 20 | PLATFORMS = [ 21 | "APNS", 22 | "FCM", 23 | "WNS", 24 | "WP", 25 | ] 26 | 27 | # Settings that all applications must have 28 | REQUIRED_SETTINGS = [ 29 | "PLATFORM", 30 | ] 31 | 32 | # Settings that an application may have to enable optional features 33 | # these settings are stubs for registry support and have no effect on the operation 34 | # of the application at this time. 35 | OPTIONAL_SETTINGS = ["APPLICATION_GROUP", "APPLICATION_SECRET"] 36 | 37 | # Since we can have an auth key, combined with a auth key id and team id *or* 38 | # a certificate, we make these all optional, and then make sure we have one or 39 | # the other (group) of settings. 40 | APNS_SETTINGS_CERT_CREDS = "CERTIFICATE" 41 | 42 | # Subkeys for APNS_SETTINGS_AUTH_CREDS 43 | APNS_AUTH_CREDS_REQUIRED = ["AUTH_KEY_PATH", "AUTH_KEY_ID", "TEAM_ID"] 44 | APNS_AUTH_CREDS_OPTIONAL = ["CERTIFICATE", "ENCRYPTION_ALGORITHM", "TOKEN_LIFETIME"] 45 | 46 | APNS_OPTIONAL_SETTINGS = ["USE_SANDBOX", "USE_ALTERNATIVE_PORT", "TOPIC"] 47 | 48 | FCM_REQUIRED_SETTINGS = [] 49 | FCM_OPTIONAL_SETTINGS = ["MAX_RECIPIENTS", "FIREBASE_APP"] 50 | 51 | WNS_REQUIRED_SETTINGS = ["PACKAGE_SECURITY_ID", "SECRET_KEY"] 52 | WNS_OPTIONAL_SETTINGS = ["WNS_ACCESS_URL"] 53 | 54 | WP_REQUIRED_SETTINGS = ["PRIVATE_KEY", "CLAIMS"] 55 | WP_OPTIONAL_SETTINGS = ["ERROR_TIMEOUT", "POST_URL"] 56 | 57 | 58 | class AppConfig(BaseConfig): 59 | """ 60 | Supports any number of push notification enabled applications. 61 | """ 62 | 63 | def __init__(self, settings: Optional[Dict[str, Any]] = None) -> None: 64 | # supports overriding the settings to be loaded. Will load from ..settings by default. 65 | self._settings = settings or SETTINGS 66 | 67 | # initialize APPLICATIONS to an empty collection 68 | self._settings.setdefault("APPLICATIONS", {}) 69 | 70 | # validate application configurations 71 | self._validate_applications(self._settings["APPLICATIONS"]) 72 | 73 | def _validate_applications(self, apps: Dict[str, Dict[str, Any]]) -> None: 74 | """Validate the application collection""" 75 | for application_id, application_config in apps.items(): 76 | self._validate_config(application_id, application_config) 77 | 78 | application_config["APPLICATION_ID"] = application_id 79 | 80 | def _validate_config( 81 | self, application_id: str, application_config: Dict[str, Any] 82 | ) -> None: 83 | platform = application_config.get("PLATFORM", None) 84 | 85 | # platform is not present 86 | if platform is None: 87 | raise ImproperlyConfigured( 88 | BAD_PLATFORM.format( 89 | application_id=application_id, 90 | code="required", 91 | platforms=", ".join(PLATFORMS), 92 | ) 93 | ) 94 | 95 | # platform is not a valid choice from PLATFORMS 96 | if platform not in PLATFORMS: 97 | raise ImproperlyConfigured( 98 | BAD_PLATFORM.format( 99 | application_id=application_id, 100 | code="invalid", 101 | platforms=", ".join(PLATFORMS), 102 | ) 103 | ) 104 | 105 | validate_fn = "_validate_{platform}_config".format(platform=platform).lower() 106 | 107 | if hasattr(self, validate_fn): 108 | getattr(self, validate_fn)(application_id, application_config) 109 | else: 110 | raise ImproperlyConfigured( 111 | UNKNOWN_PLATFORM.format( 112 | platform=platform, platforms=", ".join(PLATFORMS) 113 | ) 114 | ) 115 | 116 | def _validate_apns_config( 117 | self, application_id: str, application_config: Dict[str, Any] 118 | ) -> None: 119 | allowed = ( 120 | REQUIRED_SETTINGS 121 | + OPTIONAL_SETTINGS 122 | + APNS_AUTH_CREDS_REQUIRED 123 | + APNS_AUTH_CREDS_OPTIONAL 124 | + APNS_OPTIONAL_SETTINGS 125 | ) 126 | 127 | self._validate_allowed_settings(application_id, application_config, allowed) 128 | # We have two sets of settings, certificate and JWT auth key. 129 | # Auth Key requires 3 values, so if that is set, that will take 130 | # precedence. If None are set, we will throw an error. 131 | has_cert_creds = APNS_SETTINGS_CERT_CREDS in application_config.keys() 132 | self.has_token_creds = True 133 | for token_setting in APNS_AUTH_CREDS_REQUIRED: 134 | if token_setting not in application_config.keys(): 135 | self.has_token_creds = False 136 | break 137 | 138 | if not has_cert_creds and not self.has_token_creds: 139 | raise ImproperlyConfigured( 140 | MISSING_SETTING.format( 141 | application_id=application_id, 142 | setting=(APNS_SETTINGS_CERT_CREDS, APNS_AUTH_CREDS_REQUIRED), 143 | ) 144 | ) 145 | cert_path = None 146 | if has_cert_creds: 147 | cert_path = "CERTIFICATE" 148 | elif self.has_token_creds: 149 | cert_path = "AUTH_KEY_PATH" 150 | allowed_tokens = ( 151 | APNS_AUTH_CREDS_REQUIRED 152 | + APNS_AUTH_CREDS_OPTIONAL 153 | + APNS_OPTIONAL_SETTINGS 154 | + REQUIRED_SETTINGS 155 | ) 156 | self._validate_allowed_settings( 157 | application_id, application_config, allowed_tokens 158 | ) 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: str) -> None: 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( 179 | certfile, e 180 | ) 181 | ) 182 | 183 | def _validate_fcm_config( 184 | self, application_id: str, application_config: Dict[str, Any] 185 | ) -> None: 186 | allowed = ( 187 | REQUIRED_SETTINGS 188 | + OPTIONAL_SETTINGS 189 | + FCM_REQUIRED_SETTINGS 190 | + FCM_OPTIONAL_SETTINGS 191 | ) 192 | 193 | self._validate_allowed_settings(application_id, application_config, allowed) 194 | self._validate_required_settings( 195 | application_id, application_config, FCM_REQUIRED_SETTINGS 196 | ) 197 | 198 | application_config.setdefault("FIREBASE_APP", None) 199 | application_config.setdefault("MAX_RECIPIENTS", 1000) 200 | 201 | def _validate_wns_config( 202 | self, application_id: str, application_config: Dict[str, Any] 203 | ) -> None: 204 | allowed = ( 205 | REQUIRED_SETTINGS 206 | + OPTIONAL_SETTINGS 207 | + WNS_REQUIRED_SETTINGS 208 | + WNS_OPTIONAL_SETTINGS 209 | ) 210 | 211 | self._validate_allowed_settings(application_id, application_config, allowed) 212 | self._validate_required_settings( 213 | application_id, application_config, WNS_REQUIRED_SETTINGS 214 | ) 215 | 216 | application_config.setdefault( 217 | "WNS_ACCESS_URL", "https://login.live.com/accesstoken.srf" 218 | ) 219 | 220 | def _validate_wp_config( 221 | self, application_id: str, application_config: Dict[str, Any] 222 | ) -> None: 223 | allowed = ( 224 | REQUIRED_SETTINGS 225 | + OPTIONAL_SETTINGS 226 | + WP_REQUIRED_SETTINGS 227 | + WP_OPTIONAL_SETTINGS 228 | ) 229 | 230 | self._validate_allowed_settings(application_id, application_config, allowed) 231 | self._validate_required_settings( 232 | application_id, application_config, WP_REQUIRED_SETTINGS 233 | ) 234 | application_config.setdefault( 235 | "POST_URL", 236 | { 237 | "CHROME": "https://fcm.googleapis.com/fcm/send", 238 | "OPERA": "https://fcm.googleapis.com/fcm/send", 239 | "EDGE": "https://wns2-par02p.notify.windows.com/w", 240 | "FIREFOX": "https://updates.push.services.mozilla.com/wpush/v2", 241 | "SAFARI": "https://web.push.apple.com", 242 | }, 243 | ) 244 | application_config.setdefault("ERROR_TIMEOUT", 1) 245 | 246 | def _validate_allowed_settings( 247 | self, 248 | application_id: str, 249 | application_config: Dict[str, Any], 250 | allowed_settings: List[str], 251 | ) -> None: 252 | """Confirm only allowed settings are present.""" 253 | 254 | for setting_key in application_config.keys(): 255 | if setting_key not in allowed_settings: 256 | raise ImproperlyConfigured( 257 | "Platform {}, app {} does not support the setting: {}.".format( 258 | application_config["PLATFORM"], application_id, setting_key 259 | ) 260 | ) 261 | 262 | def _validate_required_settings( 263 | self, 264 | application_id: str, 265 | application_config: Dict[str, Any], 266 | required_settings: List[str], 267 | should_throw: bool = True, 268 | ) -> bool: 269 | """All required keys must be present""" 270 | 271 | for setting_key in required_settings: 272 | if setting_key not in application_config.keys(): 273 | if should_throw: 274 | raise ImproperlyConfigured( 275 | MISSING_SETTING.format( 276 | application_id=application_id, setting=setting_key 277 | ) 278 | ) 279 | else: 280 | return False 281 | return True 282 | 283 | def _get_application_settings( 284 | self, application_id: Optional[str], platform: str, settings_key: str 285 | ) -> Any: 286 | """ 287 | Walks through PUSH_NOTIFICATIONS_SETTINGS to find the correct setting value 288 | or raises ImproperlyConfigured. 289 | """ 290 | 291 | if not application_id: 292 | conf_cls = "push_notifications.conf.AppConfig" 293 | raise ImproperlyConfigured( 294 | "{} requires the application_id be specified at all times.".format( 295 | conf_cls 296 | ) 297 | ) 298 | 299 | # verify that the application config exists 300 | app_config = self._settings.get("APPLICATIONS").get(application_id, None) 301 | if app_config is None: 302 | raise ImproperlyConfigured( 303 | "No application configured with application_id: {}.".format( 304 | application_id 305 | ) 306 | ) 307 | 308 | # fetch a setting for the incorrect type of platform 309 | if app_config.get("PLATFORM") != platform: 310 | raise ImproperlyConfigured( 311 | SETTING_MISMATCH.format( 312 | application_id=application_id, 313 | platform=app_config.get("PLATFORM"), 314 | setting=settings_key, 315 | ) 316 | ) 317 | 318 | # finally, try to fetch the setting 319 | if settings_key not in app_config: 320 | raise ImproperlyConfigured( 321 | MISSING_SETTING.format( 322 | application_id=application_id, setting=settings_key 323 | ) 324 | ) 325 | 326 | return app_config.get(settings_key) 327 | 328 | def get_firebase_app(self, application_id: Optional[str] = None) -> Any: 329 | return self._get_application_settings(application_id, "FCM", "FIREBASE_APP") 330 | 331 | def has_auth_token_creds(self, application_id: Optional[str] = None) -> bool: 332 | return self.has_token_creds 333 | 334 | def get_max_recipients(self, application_id: Optional[str] = None) -> int: 335 | return self._get_application_settings(application_id, "FCM", "MAX_RECIPIENTS") 336 | 337 | def get_apns_certificate(self, application_id: Optional[str] = None) -> str: 338 | r = self._get_application_settings(application_id, "APNS", "CERTIFICATE") 339 | if not isinstance(r, str): 340 | # probably the (Django) file, and file path should be got 341 | if hasattr(r, "path"): 342 | return r.path 343 | elif (hasattr(r, "has_key") or hasattr(r, "__contains__")) and "path" in r: 344 | return r["path"] 345 | else: 346 | raise ImproperlyConfigured( 347 | "The APNS certificate settings value should be a string, or " 348 | "should have a 'path' attribute or key" 349 | ) 350 | return r 351 | 352 | def get_apns_auth_creds( 353 | self, application_id: Optional[str] = None 354 | ) -> Tuple[str, str, str]: 355 | return ( 356 | self._get_apns_auth_key_path(application_id), 357 | self._get_apns_auth_key_id(application_id), 358 | self._get_apns_team_id(application_id), 359 | ) 360 | 361 | def _get_apns_auth_key_path(self, application_id: Optional[str] = None) -> str: 362 | return self._get_application_settings(application_id, "APNS", "AUTH_KEY_PATH") 363 | 364 | def _get_apns_auth_key_id(self, application_id: Optional[str] = None) -> str: 365 | return self._get_application_settings(application_id, "APNS", "AUTH_KEY_ID") 366 | 367 | def _get_apns_team_id(self, application_id: Optional[str] = None) -> str: 368 | return self._get_application_settings(application_id, "APNS", "TEAM_ID") 369 | 370 | def get_apns_use_sandbox(self, application_id: Optional[str] = None) -> bool: 371 | return self._get_application_settings(application_id, "APNS", "USE_SANDBOX") 372 | 373 | def get_apns_use_alternative_port( 374 | self, application_id: Optional[str] = None 375 | ) -> bool: 376 | return self._get_application_settings( 377 | application_id, "APNS", "USE_ALTERNATIVE_PORT" 378 | ) 379 | 380 | def get_apns_topic(self, application_id: Optional[str] = None) -> Optional[str]: 381 | return self._get_application_settings(application_id, "APNS", "TOPIC") 382 | 383 | def get_wns_package_security_id(self, application_id: Optional[str] = None) -> str: 384 | return self._get_application_settings( 385 | application_id, "WNS", "PACKAGE_SECURITY_ID" 386 | ) 387 | 388 | def get_wns_secret_key(self, application_id: Optional[str] = None) -> str: 389 | return self._get_application_settings(application_id, "WNS", "SECRET_KEY") 390 | 391 | def get_wp_post_url(self, application_id: str, browser: str) -> str: 392 | return self._get_application_settings(application_id, "WP", "POST_URL")[browser] 393 | 394 | def get_wp_private_key(self, application_id: Optional[str] = None) -> str: 395 | return self._get_application_settings(application_id, "WP", "PRIVATE_KEY") 396 | 397 | def get_wp_claims(self, application_id: Optional[str] = None) -> Dict[str, Any]: 398 | return self._get_application_settings(application_id, "WP", "CLAIMS") 399 | 400 | def get_wp_error_timeout(self, application_id: Optional[str] = None) -> int: 401 | return self._get_application_settings(application_id, "WP", "ERROR_TIMEOUT") 402 | --------------------------------------------------------------------------------