From 0a7274e4a6ce808313ecf82b3c78e3c4b1004927 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 12 Aug 2022 12:29:29 -0400 Subject: [PATCH 01/31] [pre-commit.ci] pre-commit autoupdate (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- README.rst | 2 -- push_notifications/conf/__init__.py | 2 +- push_notifications/conf/app.py | 3 ++- push_notifications/gcm.py | 1 - push_notifications/migrations/0001_initial.py | 1 + push_notifications/migrations/0002_auto_20160106_0850.py | 1 + push_notifications/migrations/0003_wnsdevice.py | 1 + push_notifications/migrations/0004_fcm.py | 1 + push_notifications/migrations/0005_applicationid.py | 1 + push_notifications/migrations/0006_webpushdevice.py | 1 + push_notifications/settings.py | 5 ----- setup.cfg | 1 + tests/test_legacy_config.py | 1 + 15 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7443eabc..6bc4cf99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: build: if: github.repository == 'jazzband/django-push-notifications' - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75779548..c5400c3b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: diff --git a/README.rst b/README.rst index 41b66bb6..042d4e31 100644 --- a/README.rst +++ b/README.rst @@ -308,7 +308,6 @@ When creating an ``APNSDevice``, the ``registration_id`` is validated to be a 64 Routes can be added one of two ways: - Routers_ (include all views) - .. _Routers: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#using-routers :: @@ -327,7 +326,6 @@ Routes can be added one of two ways: ) - Using as_view_ (specify which views to include) - .. _as_view: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#binding-viewsets-to-urls-explicitly :: diff --git a/push_notifications/conf/__init__.py b/push_notifications/conf/__init__.py index e11f28fb..4e798ccd 100644 --- a/push_notifications/conf/__init__.py +++ b/push_notifications/conf/__init__.py @@ -1,9 +1,9 @@ from django.utils.module_loading import import_string -from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS # noqa: I001 from .app import AppConfig # noqa: F401 from .appmodel import AppModelConfig # noqa: F401 from .legacy import LegacyConfig # noqa: F401 +from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS # noqa: I001 manager = None diff --git a/push_notifications/conf/app.py b/push_notifications/conf/app.py index a70aa1f5..308b53eb 100644 --- a/push_notifications/conf/app.py +++ b/push_notifications/conf/app.py @@ -25,6 +25,7 @@ PLATFORMS = [ "APNS", "FCM", + "GCM", "WNS", "WP", ] @@ -170,7 +171,7 @@ def _validate_apns_certificate(self, certfile): """Validate the APNS certificate at startup.""" try: - with open(certfile) as f: + with open(certfile, "r") as f: content = f.read() check_apns_certificate(content) except Exception as e: diff --git a/push_notifications/gcm.py b/push_notifications/gcm.py index e2f9d537..923322e9 100644 --- a/push_notifications/gcm.py +++ b/push_notifications/gcm.py @@ -16,7 +16,6 @@ # Valid keys for FCM messages. Reference: # https://firebase.google.com/docs/cloud-messaging/http-server-ref - FCM_NOTIFICATIONS_PAYLOAD_KEYS = [ "title", "body", "icon", "image", "sound", "badge", "color", "tag", "click_action", "body_loc_key", "body_loc_args", "title_loc_key", "title_loc_args", "android_channel_id" diff --git a/push_notifications/migrations/0001_initial.py b/push_notifications/migrations/0001_initial.py index eca6aefd..14033f16 100644 --- a/push_notifications/migrations/0001_initial.py +++ b/push_notifications/migrations/0001_initial.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0002_auto_20160106_0850.py b/push_notifications/migrations/0002_auto_20160106_0850.py index 8c0373dd..37bb9f8f 100644 --- a/push_notifications/migrations/0002_auto_20160106_0850.py +++ b/push_notifications/migrations/0002_auto_20160106_0850.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Generated by Django 1.9.1 on 2016-01-06 08:50 from django.db import migrations, models diff --git a/push_notifications/migrations/0003_wnsdevice.py b/push_notifications/migrations/0003_wnsdevice.py index 2ac1dad9..6c6b5c3a 100644 --- a/push_notifications/migrations/0003_wnsdevice.py +++ b/push_notifications/migrations/0003_wnsdevice.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Generated by Django 1.9.6 on 2016-06-13 20:46 import django.db.models.deletion from django.conf import settings diff --git a/push_notifications/migrations/0004_fcm.py b/push_notifications/migrations/0004_fcm.py index c08c28c1..be35f4c4 100644 --- a/push_notifications/migrations/0004_fcm.py +++ b/push_notifications/migrations/0004_fcm.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Generated by Django 1.9.6 on 2016-06-13 20:46 from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0005_applicationid.py b/push_notifications/migrations/0005_applicationid.py index 443c173d..79b5919e 100644 --- a/push_notifications/migrations/0005_applicationid.py +++ b/push_notifications/migrations/0005_applicationid.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0006_webpushdevice.py b/push_notifications/migrations/0006_webpushdevice.py index 524cf6bc..c63c2490 100644 --- a/push_notifications/migrations/0006_webpushdevice.py +++ b/push_notifications/migrations/0006_webpushdevice.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/settings.py b/push_notifications/settings.py index 1d86ec02..742063d5 100644 --- a/push_notifications/settings.py +++ b/push_notifications/settings.py @@ -27,11 +27,6 @@ ) # WP (WebPush) - -PUSH_NOTIFICATIONS_SETTINGS.setdefault( - "FCM_POST_URL", "https://fcm.googleapis.com/fcm/send" -) - PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_POST_URL", { "CHROME": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], "OPERA": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], diff --git a/setup.cfg b/setup.cfg index 99dfc8c8..dbf50936 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ setup_requires = APNS = apns2>=0.3.0 importlib-metadata;python_version < "3.8" + pywebpush>=1.3.0 Django>=2.2 WP = pywebpush>=1.3.0 diff --git a/tests/test_legacy_config.py b/tests/test_legacy_config.py index 0cd91f24..f441d425 100644 --- a/tests/test_legacy_config.py +++ b/tests/test_legacy_config.py @@ -3,6 +3,7 @@ from django.test import TestCase from push_notifications.conf import get_manager +from push_notifications.conf.legacy import LegacyConfig from push_notifications.exceptions import WebPushError from push_notifications.webpush import webpush_send_message From b62200db26a60493dc29767ba0f4ee0ba92bee1b Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Tue, 15 Aug 2023 19:33:21 -0400 Subject: [PATCH 02/31] Update tox.ini (#688) * Update tox.ini * Update release.yml --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bc4cf99..7443eabc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: build: if: github.repository == 'jazzband/django-push-notifications' - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 From 8a22456ee7ef8f13cd541e0ab6e3789d1a45d78d Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Tue, 15 Aug 2023 20:49:43 -0400 Subject: [PATCH 03/31] Update test.yml (#689) * Update test.yml Fixes failing python 3.6 tests * Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c5400c3b..75779548 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: fail-fast: false matrix: From 25784b24cd7d5a88669594d83753533881c62194 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 16 Aug 2023 10:39:01 -0400 Subject: [PATCH 04/31] [fix] resolve rebase conflicts - pre-commit PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#669) * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0) - [github.com/asottile/pyupgrade: v2.37.3 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v2.37.3...v3.10.1) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: jscarlett --- push_notifications/conf/app.py | 2 +- push_notifications/migrations/0001_initial.py | 1 - push_notifications/migrations/0002_auto_20160106_0850.py | 1 - push_notifications/migrations/0003_wnsdevice.py | 1 - push_notifications/migrations/0004_fcm.py | 1 - push_notifications/migrations/0005_applicationid.py | 1 - push_notifications/migrations/0006_webpushdevice.py | 1 - tests/test_models.py | 8 ++++++++ 8 files changed, 9 insertions(+), 7 deletions(-) diff --git a/push_notifications/conf/app.py b/push_notifications/conf/app.py index 308b53eb..da91e3be 100644 --- a/push_notifications/conf/app.py +++ b/push_notifications/conf/app.py @@ -171,7 +171,7 @@ def _validate_apns_certificate(self, certfile): """Validate the APNS certificate at startup.""" try: - with open(certfile, "r") as f: + with open(certfile) as f: content = f.read() check_apns_certificate(content) except Exception as e: diff --git a/push_notifications/migrations/0001_initial.py b/push_notifications/migrations/0001_initial.py index 14033f16..eca6aefd 100644 --- a/push_notifications/migrations/0001_initial.py +++ b/push_notifications/migrations/0001_initial.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0002_auto_20160106_0850.py b/push_notifications/migrations/0002_auto_20160106_0850.py index 37bb9f8f..8c0373dd 100644 --- a/push_notifications/migrations/0002_auto_20160106_0850.py +++ b/push_notifications/migrations/0002_auto_20160106_0850.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.1 on 2016-01-06 08:50 from django.db import migrations, models diff --git a/push_notifications/migrations/0003_wnsdevice.py b/push_notifications/migrations/0003_wnsdevice.py index 6c6b5c3a..2ac1dad9 100644 --- a/push_notifications/migrations/0003_wnsdevice.py +++ b/push_notifications/migrations/0003_wnsdevice.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.6 on 2016-06-13 20:46 import django.db.models.deletion from django.conf import settings diff --git a/push_notifications/migrations/0004_fcm.py b/push_notifications/migrations/0004_fcm.py index be35f4c4..c08c28c1 100644 --- a/push_notifications/migrations/0004_fcm.py +++ b/push_notifications/migrations/0004_fcm.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.9.6 on 2016-06-13 20:46 from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0005_applicationid.py b/push_notifications/migrations/0005_applicationid.py index 79b5919e..443c173d 100644 --- a/push_notifications/migrations/0005_applicationid.py +++ b/push_notifications/migrations/0005_applicationid.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/push_notifications/migrations/0006_webpushdevice.py b/push_notifications/migrations/0006_webpushdevice.py index c63c2490..524cf6bc 100644 --- a/push_notifications/migrations/0006_webpushdevice.py +++ b/push_notifications/migrations/0006_webpushdevice.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from django.conf import settings from django.db import migrations, models diff --git a/tests/test_models.py b/tests/test_models.py index 2575fc4c..d7323dc6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -459,9 +459,17 @@ def test_fcm_send_message_with_no_reg_ids(self): return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()] +<<<<<<< HEAD message = dict_to_fcm_message({"message": "Hello World"}) send_bulk_message(reg_ids, message) p.assert_called_once() +======= + send_bulk_message(reg_ids, {"message": "Hello World"}, "FCM") + p.assert_called_once_with( + ["abc", "abc1"], {"message": "Hello World"}, cloud_type="FCM", + application_id=None + ) +>>>>>>> 34d6c54 ([pre-commit.ci] pre-commit autoupdate (#669)) def test_can_save_wsn_device(self): device = GCMDevice.objects.create(registration_id="a valid registration id") From 6f9b3acb43eebdb3a90facbde8b291370621318f Mon Sep 17 00:00:00 2001 From: Neil Littlejohns Date: Wed, 16 Aug 2023 10:50:38 -0400 Subject: [PATCH 05/31] Expanded documentation for Web Push (#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added example code to register WP device • Fixed issue where call to extract UserAgent didn't include UA • Added examples on how to send a Web Push message --- README.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.rst b/README.rst index 042d4e31..0b1ee6aa 100644 --- a/README.rst +++ b/README.rst @@ -207,6 +207,37 @@ JSON example: device.send_message(data) +Web Push accepts only one variable (``message``), which is passed directly to pywebpush. This message can be a simple string, which will be used as your notification's body, or it can be contain `any data supported by pywebpush`. + +Simple example: + +.. code-block:: python + + from push_notifications.models import WebPushDevice + + device = WebPushDevice.objects.get(registration_id=wp_reg_id) + + device.send_message("You've got mail") + +.. note:: + To customize the notification title using this method, edit the ``"TITLE DEFAULT"`` string in your ``navigatorPush.service.js`` file. + +JSON example: + +.. code-block:: python + + import json + from push_notifications.models import WebPushDevice + + device = WebPushDevice.objects.get(registration_id=wp_reg_id) + + title = "Message Received" + message = "You've got mail" + data = json.dumps({"title": title, "message": message}) + + device.send_message(data) + + Sending messages in bulk ------------------------ .. code-block:: python From 5f8d2bd5ac40cc24ea96aeedf1556f12e34a136c Mon Sep 17 00:00:00 2001 From: Ian Later Date: Fri, 18 Aug 2023 20:21:38 -0700 Subject: [PATCH 06/31] Allow APNS tokens of variable length. (#678) Co-authored-by: Ian Later --- tests/test_rest_framework.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 0f5dd257..87235fb8 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -53,6 +53,14 @@ def test_validation(self): }) self.assertTrue(serializer.is_valid()) + # valid data - 200 bytes mixed case + serializer = APNSDeviceSerializer(data={ + "registration_id": "aE" * 200, + "name": "Apple iPhone 6+", + "device_id": "ffffffffffffffffffffffffffffffff", + }) + self.assertTrue(serializer.is_valid()) + # invalid data - device_id, registration_id serializer = APNSDeviceSerializer(data={ "registration_id": "invalid device token contains no hex", From 39f8c177bcacb9d7d71eea7aef8f2c2bf42de0e4 Mon Sep 17 00:00:00 2001 From: James Bligh <618250+blighj@users.noreply.github.com> Date: Sun, 8 Oct 2023 04:15:56 +0100 Subject: [PATCH 07/31] Add WebPush support for Safari (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add WebPush support for Safari * Update webpush.py based on review Declare results variable at the start of the block * Fix typo in warning Co-authored-by: Cuyler Stuwe * Update README.rst Co-authored-by: James Bligh <618250+blighj@users.noreply.github.com> * Fix mailto: space * Expanded documentation for Web Push (#558) • Added example code to register WP device • Fixed issue where call to extract UserAgent didn't include UA • Added examples on how to send a Web Push message * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Attempt to fix tests * Update README.rst --------- Co-authored-by: James Bligh Co-authored-by: Cuyler Stuwe Co-authored-by: Éloi Rivard Co-authored-by: Neil Littlejohns Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- setup.cfg | 1 - tests/test_rest_framework.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index dbf50936..99dfc8c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,6 @@ setup_requires = APNS = apns2>=0.3.0 importlib-metadata;python_version < "3.8" - pywebpush>=1.3.0 Django>=2.2 WP = pywebpush>=1.3.0 diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 87235fb8..53e751ee 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -55,7 +55,7 @@ def test_validation(self): # valid data - 200 bytes mixed case serializer = APNSDeviceSerializer(data={ - "registration_id": "aE" * 200, + "registration_id": "aE" * 100, "name": "Apple iPhone 6+", "device_id": "ffffffffffffffffffffffffffffffff", }) From 5b89f7bddad607ef3d6a20af7dbb259a8176646e Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Wed, 18 Oct 2023 11:40:30 -0400 Subject: [PATCH 08/31] Update setup.py (#693) Add long_description settings to setup.py to address `twine check` errors --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 719346b7..759840f2 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from pathlib import Path from setuptools import setup - +from pathlib import Path this_directory = Path(__file__).parent long_description = (this_directory / "README.rst").read_text() From d889d361c484abe34667907f37b0f0f5b2da1afc Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Sun, 29 Oct 2023 12:59:32 -0400 Subject: [PATCH 09/31] Fixes twine reported warnings when building package. (#695) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 0b1ee6aa..abcf1cc2 100644 --- a/README.rst +++ b/README.rst @@ -339,6 +339,7 @@ When creating an ``APNSDevice``, the ``registration_id`` is validated to be a 64 Routes can be added one of two ways: - Routers_ (include all views) + .. _Routers: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#using-routers :: @@ -357,6 +358,7 @@ Routes can be added one of two ways: ) - Using as_view_ (specify which views to include) + .. _as_view: http://www.django-rest-framework.org/tutorial/6-viewsets-and-routers#binding-viewsets-to-urls-explicitly :: From 9a290c3b12d64b5c3346fe31f0221b9baf6e9316 Mon Sep 17 00:00:00 2001 From: pom Date: Thu, 2 Nov 2023 16:12:41 +0100 Subject: [PATCH 10/31] Add aioapns version of APNS Since version python 3.10+ is not supported by `apns2` (because of dependency on `hyper`) we add `aioapns` version of sending APNs notifications. We add installation extra `[APNS_ASYNC]` that installs aioapns and use new version of service if `aioapns` is installed. Tests are also conditional on installed modules/version of python. --- push_notifications/apns_async.py | 347 ++++++++++++++++++++++++++ push_notifications/models.py | 10 +- setup.cfg | 2 + tests/test_apns_async_models.py | 166 ++++++++++++ tests/test_apns_async_push_payload.py | 171 +++++++++++++ tests/test_apns_models.py | 50 ++-- tests/test_apns_push_payload.py | 18 +- tox.ini | 5 + 8 files changed, 748 insertions(+), 21 deletions(-) create mode 100644 push_notifications/apns_async.py create mode 100644 tests/test_apns_async_models.py create mode 100644 tests/test_apns_async_push_payload.py diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py new file mode 100644 index 00000000..398dc447 --- /dev/null +++ b/push_notifications/apns_async.py @@ -0,0 +1,347 @@ +import asyncio +from dataclasses import asdict, dataclass +import time +from typing import Union + +from aioapns import APNs, NotificationRequest, ConnectionError + +from . import models +from .conf import get_manager +from .exceptions import APNSServerError + + +class NotSet: + def __init__(self): + raise RuntimeError("NotSet cannot be instantiated") + + +class Credentials: + pass + + +@dataclass +class TokenCredentials(Credentials): + key: str + key_id: str + team_id: str + + +@dataclass +class CertificateCredentials(Credentials): + client_cert: str + + +@dataclass +class Alert: + """ + The information for displaying an alert. A dictionary is recommended. If you specify a string, the alert displays your string as the body text. + + https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification + """ + + title: str = NotSet + """ + The title of the notification. Apple Watch displays this string in the short look notification interface. Specify a string that’s quickly understood by the user. + """ + + subtitle: str = NotSet + """ + Additional information that explains the purpose of the notification. + """ + + body: str = NotSet + """ + The content of the alert message. + """ + + launch_image: str = NotSet + """ + The name of the launch image file to display. If the user chooses to launch your app, the contents of the specified image or storyboard file are displayed instead of your app’s normal launch image. + """ + + title_loc_key: str = NotSet + """ + The key for a localized title string. Specify this key instead of the title key to retrieve the title from your app’s Localizable.strings files. The value must contain the name of a key in your strings file + """ + + title_loc_args: list[str] = NotSet + """ + An array of strings containing replacement values for variables in your title string. Each %@ character in the string specified by the title-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. + """ + + subtitle_loc_key: str = NotSet + """ + The key for a localized subtitle string. Use this key, instead of the subtitle key, to retrieve the subtitle from your app’s Localizable.strings file. The value must contain the name of a key in your strings file. + """ + + subtitle_loc_args: list[str] = NotSet + """ + An array of strings containing replacement values for variables in your title string. Each %@ character in the string specified by subtitle-loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. + """ + + loc_key: str = NotSet + """ + The key for a localized message string. Use this key, instead of the body key, to retrieve the message text from your app’s Localizable.strings file. The value must contain the name of a key in your strings file. + """ + + loc_args: list[str] = NotSet + """ + An array of strings containing replacement values for variables in your message text. Each %@ character in the string specified by loc-key is replaced by a value from this array. The first item in the array replaces the first instance of the %@ character in the string, the second item replaces the second instance, and so on. + """ + + sound: Union[str, any] = NotSet + """ + string + The name of a sound file in your app’s main bundle or in the Library/Sounds folder of your app’s container directory. Specify the string “default” to play the system sound. Use this key for regular notifications. For critical alerts, use the sound dictionary instead. For information about how to prepare sounds, see UNNotificationSound. + + dictionary + A dictionary that contains sound information for critical alerts. For regular notifications, use the sound string instead. + """ + + def asDict(self) -> dict[str, any]: + python_dict = asdict(self) + return { + key.replace("_", "-"): value + for key, value in python_dict.items() + if value is not NotSet + } + + +class APNsService: + __slots__ = ("client",) + + def __init__( + self, application_id: str = None, creds: Credentials = None, topic: str = None + ): + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + self.client = self._create_client( + creds=creds, application_id=application_id, topic=topic + ) + + def send_message( + self, + request: NotificationRequest, + ): + print("Sending {} to {}".format(request, request.device_token)) + + loop = asyncio.get_event_loop() + res1 = self.client.send_notification(request) + res = loop.run_until_complete(res1) + return res + + def _create_notification_request_from_args( + self, + registration_id: str, + alert: Union[str, Alert], + badge: int = None, + sound: str = None, + extra: dict = {}, + expiration: int = None, + thread_id: str = None, + loc_key: str = None, + priority: int = None, + collapse_id: str = None, + aps_kwargs: dict = {}, + message_kwargs: dict = {}, + notification_request_kwargs: dict = {}, + ): + if alert is None: + alert = Alert(body="") + + if loc_key: + if isinstance(alert, str): + alert = Alert(body=alert) + alert.loc_key = loc_key + + if isinstance(alert, Alert): + alert = alert.asDict() + + if expiration is not None: + notification_request_kwargs["time_to_live"] = expiration - int(time.time()) + if priority is not None: + notification_request_kwargs["priority"] = priority + + if collapse_id is not None: + notification_request_kwargs["collapse_key"] = collapse_id + + request = NotificationRequest( + device_token=registration_id, + message={ + "aps": { + "alert": alert, + "badge": badge, + "sound": sound, + "thread-id": thread_id, + **aps_kwargs, + }, + **extra, + **message_kwargs, + }, + **notification_request_kwargs, + ) + + return request + + def _create_client( + self, creds: Credentials = None, application_id: str = None, topic=None + ) -> APNs: + use_sandbox = get_manager().get_apns_use_sandbox(application_id) + if topic is None: + topic = get_manager().get_apns_topic(application_id) + if creds is None: + creds = self._get_credentials(application_id) + + print(creds) + client = APNs( + **asdict(creds), + topic=topic, # Bundle ID + use_sandbox=use_sandbox, + ) + return client + + def _get_credentials(self, application_id): + if not get_manager().has_auth_token_creds(application_id): + # TLS certificate authentication + cert = get_manager().get_apns_certificate(application_id) + return CertificateCredentials( + client_cert=cert, + ) + else: + # Token authentication + keyPath, keyId, teamId = get_manager().get_apns_auth_creds(application_id) + # No use getting a lifetime because this credential is + # ephemeral, but if you're looking at this to see how to + # create a credential, you could also pass the lifetime and + # algorithm. Neither of those settings are exposed in the + # settings API at the moment. + return TokenCredentials(key=keyPath, key_id=keyId, team_id=teamId) + + +## Public interface + + +def apns_send_message( + registration_id: str, + alert: Union[str, Alert], + application_id: str = None, + creds: Credentials = None, + topic: str = None, + badge: int = None, + sound: str = None, + extra: dict = {}, + expiration: int = None, + thread_id: str = None, + loc_key: str = None, + priority: int = None, + collapse_id: str = None, +): + """ + Sends an APNS notification to a single registration_id. + If sending multiple notifications, it is more efficient to use + apns_send_bulk_message() + + Note that if set alert should always be a string. If it is not set, + it won"t be included in the notification. You will need to pass None + to this for silent notifications. + + + :param registration_id: The registration_id of the device to send to + :param alert: The alert message to send + :param application_id: The application_id to use + :param creds: The credentials to use + """ + + try: + apns_service = APNsService( + application_id=application_id, creds=creds, topic=topic + ) + print(badge) + request = apns_service._create_notification_request_from_args( + registration_id, + alert, + badge=badge, + sound=sound, + extra=extra, + expiration=expiration, + thread_id=thread_id, + loc_key=loc_key, + priority=priority, + collapse_id=collapse_id, + ) + res = apns_service.send_message(request) + if not res.is_successful: + if res.description == "Unregistered": + models.APNSDevice.objects.filter( + registration_id=registration_id + ).update(active=False) + raise APNSServerError(status=res.description) + print(res) + except ConnectionError as e: + raise APNSServerError(status=e.__class__.__name__) + + +def apns_send_bulk_message( + registration_ids: list[str], + alert: Union[str, Alert], + application_id: str = None, + creds: Credentials = None, + topic: str = None, + badge: int = None, + sound: str = None, + extra: dict = {}, + expiration: int = None, + thread_id: str = None, + loc_key: str = None, + priority: int = None, + collapse_id: str = None, +): + """ + Sends an APNS notification to one or more registration_ids. + The registration_ids argument needs to be a list. + + Note that if set alert should always be a string. If it is not set, + it won"t be included in the notification. You will need to pass None + to this for silent notifications. + + :param registration_ids: A list of the registration_ids to send to + :param alert: The alert message to send + :param application_id: The application_id to use + :param creds: The credentials to use + """ + + topic = get_manager().get_apns_topic(application_id) + results = {} + inactive_tokens = [] + apns_service = APNsService(application_id=application_id, creds=creds, topic=topic) + for registration_id in registration_ids: + + request = apns_service._create_notification_request_from_args( + registration_id, + alert, + badge=badge, + sound=sound, + extra=extra, + expiration=expiration, + thread_id=thread_id, + loc_key=loc_key, + priority=priority, + collapse_id=collapse_id, + ) + + result = apns_service.send_message( + request + ) + results[registration_id] = "Success" if result.is_successful else result.description + if not result.is_successful and result.description == "Unregistered": + inactive_tokens.append(registration_id) + + if len(inactive_tokens) > 0: + models.APNSDevice.objects.filter(registration_id__in=inactive_tokens).update( + active=False + ) + return results diff --git a/push_notifications/models.py b/push_notifications/models.py index 33f44205..2f49ff8d 100644 --- a/push_notifications/models.py +++ b/push_notifications/models.py @@ -135,7 +135,10 @@ def get_queryset(self): class APNSDeviceQuerySet(models.query.QuerySet): def send_message(self, message, creds=None, **kwargs): if self.exists(): - from .apns import apns_send_bulk_message + try: + from .apns_async import apns_send_bulk_message + except ImportError: + from .apns import apns_send_bulk_message app_ids = self.filter(active=True).order_by("application_id") \ .values_list("application_id", flat=True).distinct() @@ -170,7 +173,10 @@ class Meta: verbose_name = _("APNS device") def send_message(self, message, creds=None, **kwargs): - from .apns import apns_send_message + try: + from .apns_async import apns_send_message + except ImportError: + from .apns import apns_send_message return apns_send_message( registration_id=self.registration_id, diff --git a/setup.cfg b/setup.cfg index 99dfc8c8..e4f0c7be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP Topic :: System :: Networking @@ -42,6 +43,7 @@ APNS = WP = pywebpush>=1.3.0 FCM = firebase-admin>=6.2 +APNS_ASYNC = aioapns>=3.1 [options.packages.find] diff --git a/tests/test_apns_async_models.py b/tests/test_apns_async_models.py new file mode 100644 index 00000000..50addd2f --- /dev/null +++ b/tests/test_apns_async_models.py @@ -0,0 +1,166 @@ +import sys +import time +from unittest import mock + +from django.conf import settings +from django.test import TestCase, override_settings +import pytest + +try: + from push_notifications.exceptions import APNSError + from push_notifications.models import APNSDevice + + from aioapns.common import NotificationResult +except ModuleNotFoundError: + # skipping because apns2 is not supported on python 3.10 + # it uses hyper that imports from collections which were changed in 3.10 + # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" + if sys.version_info < (3, 10): + pytest.skip(allow_module_level=True) + else: + raise + + +class APNSModelTestCase(TestCase): + def _create_devices(self, devices): + for device in devices: + APNSDevice.objects.create(registration_id=device) + + @override_settings() + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_bulk_message(self, mock_apns): + self._create_devices(["abc", "def"]) + + # legacy conf manager requires a value + settings.PUSH_NOTIFICATIONS_SETTINGS.update( + {"APNS_CERTIFICATE": "/path/to/apns/certificate.pem"} + ) + + APNSDevice.objects.all().send_message("Hello world", expiration=time.time() + 3) + + [call1, call2] = mock_apns.return_value.send_notification.call_args_list + print(call1) + req1 = call1.args[0] + req2 = call2.args[0] + print(dir(req1)) + + self.assertEqual(req1.device_token, "abc") + self.assertEqual(req2.device_token, "def") + self.assertEqual(req1.message["aps"]["alert"], "Hello world") + self.assertEqual(req2.message["aps"]["alert"], "Hello world") + self.assertAlmostEqual(req1.time_to_live, 3, places=-1) + self.assertAlmostEqual(req2.time_to_live, 3, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_message_extra(self, mock_apns): + self._create_devices(["abc"]) + APNSDevice.objects.get().send_message( + "Hello world", expiration=time.time() + 2, priority=5, extra={"foo": "bar"} + ) + + args, kargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "abc") + self.assertEqual(req.message["aps"]["alert"], "Hello world") + self.assertEqual(req.message["foo"], "bar") + self.assertEqual(req.priority, 5) + self.assertAlmostEqual(req.time_to_live, 2, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_message(self, mock_apns): + self._create_devices(["abc"]) + APNSDevice.objects.get().send_message("Hello world", expiration=time.time() + 1) + + args, kargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "abc") + self.assertEqual(req.message["aps"]["alert"], "Hello world") + self.assertAlmostEqual(req.time_to_live, 1, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_message_to_single_device_with_error(self, mock_apns): + # these errors are device specific, device.active will be set false + devices = ["abc"] + self._create_devices(devices) + + mock_apns.return_value.send_notification.return_value = NotificationResult( + status="400", + notification_id="abc", + description="Unregistered", + ) + device = APNSDevice.objects.get(registration_id="abc") + with self.assertRaises(APNSError) as ae: + device.send_message("Hello World!") + self.assertEqual(ae.exception.status, "Unregistered") + self.assertFalse(APNSDevice.objects.get(registration_id="abc").active) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_message_to_several_devices_with_error(self, mock_apns): + # these errors are device specific, device.active will be set false + devices = ["abc", "def", "ghi"] + expected_exceptions_statuses = ["PayloadTooLarge", "BadTopic", "Unregistered"] + self._create_devices(devices) + + mock_apns.return_value.send_notification.side_effect = [ + NotificationResult( + status="400", + notification_id="abc", + description="PayloadTooLarge", + ), + NotificationResult( + status="400", + notification_id="def", + description="BadTopic", + ), + NotificationResult( + status="400", + notification_id="ghi", + description="Unregistered", + ), + ] + + for idx, token in enumerate(devices): + device = APNSDevice.objects.get(registration_id=token) + with self.assertRaises(APNSError) as ae: + device.send_message("Hello World!") + self.assertEqual(ae.exception.status, expected_exceptions_statuses[idx]) + + if idx == 2: + self.assertFalse(APNSDevice.objects.get(registration_id=token).active) + else: + self.assertTrue(APNSDevice.objects.get(registration_id=token).active) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_message_to_bulk_devices_with_error(self, mock_apns): + # these errors are device specific, device.active will be set false + devices = ["abc", "def", "ghi"] + results = [ + NotificationResult( + status="400", + notification_id="abc", + description="PayloadTooLarge", + ), + NotificationResult( + status="400", + notification_id="def", + description="BadTopic", + ), + NotificationResult( + status="400", + notification_id="ghi", + description="Unregistered", + ), + ] + self._create_devices(devices) + + mock_apns.return_value.send_notification.side_effect = results + + results = APNSDevice.objects.all().send_message("Hello World!") + + for idx, token in enumerate(devices): + if idx == 2: + self.assertFalse(APNSDevice.objects.get(registration_id=token).active) + else: + self.assertTrue(APNSDevice.objects.get(registration_id=token).active) diff --git a/tests/test_apns_async_push_payload.py b/tests/test_apns_async_push_payload.py new file mode 100644 index 00000000..60160392 --- /dev/null +++ b/tests/test_apns_async_push_payload.py @@ -0,0 +1,171 @@ +import sys +import time +from unittest import mock + +import pytest +from django.test import TestCase + +try: + + from push_notifications.apns_async import ( + TokenCredentials, + apns_send_message, + ) +except ModuleNotFoundError: + # skipping because apns2 is not supported on python 3.10 + # it uses hyper that imports from collections which were changed in 3.10 + # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" + if sys.version_info < (3, 10): + pytest.skip(allow_module_level=True) + else: + raise + + + +class APNSAsyncPushPayloadTest(TestCase): + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_push_payload(self, mock_apns): + _res = apns_send_message( + "123", + "Hello world", + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + badge=1, + sound="chime", + extra={"custom_data": 12345}, + expiration=int(time.time()) + 3, + ) + self.assertTrue(mock_apns.called) + args, kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"], "Hello world") + self.assertEqual(req.message["aps"]["badge"], 1) + self.assertEqual(req.message["aps"]["sound"], "chime") + self.assertEqual(req.message["custom_data"], 12345) + self.assertEqual(req.time_to_live, 3) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_push_payload_with_thread_id(self, mock_apns): + _res = apns_send_message( + "123", + "Hello world", + thread_id="565", + sound="chime", + extra={"custom_data": 12345}, + expiration=int(time.time()) + 3, + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + ) + args, kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"], "Hello world") + self.assertEqual(req.message["aps"]["thread-id"], "565") + self.assertEqual(req.message["aps"]["sound"], "chime") + self.assertEqual(req.message["custom_data"], 12345) + self.assertAlmostEqual(req.time_to_live, 3, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_push_payload_with_alert_dict(self, mock_apns): + _res = apns_send_message( + "123", + alert={"title": "t1", "body": "b1"}, + sound="chime", + extra={"custom_data": 12345}, + expiration=int(time.time()) + 3, + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + ) + + args, kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"]["body"], "b1") + self.assertEqual(req.message["aps"]["alert"]["title"], "t1") + self.assertEqual(req.message["aps"]["sound"], "chime") + self.assertEqual(req.message["custom_data"], 12345) + self.assertAlmostEqual(req.time_to_live, 3, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_localised_push_with_empty_body(self, mock_apns): + _res = apns_send_message( + "123", + None, + loc_key="TEST_LOC_KEY", + expiration=time.time() + 3, + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + ) + + args, _kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"]["loc-key"], "TEST_LOC_KEY") + self.assertAlmostEqual(req.time_to_live, 3, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_using_extra(self, mock_apns): + apns_send_message( + "123", + "sample", + extra={"foo": "bar"}, + expiration=(time.time() + 30), + priority=10, + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + ) + + args, _kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"], "sample") + self.assertEqual(req.message["foo"], "bar") + self.assertEqual(req.priority, 10) + self.assertAlmostEqual(req.time_to_live, 30, places=-1) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_collapse_id(self, mock_apns): + _res = apns_send_message( + "123", + "sample", + collapse_id="456789", + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + ) + + args, kwargs = mock_apns.return_value.send_notification.call_args + req = args[0] + + self.assertEqual(req.device_token, "123") + self.assertEqual(req.message["aps"]["alert"], "sample") + self.assertEqual(req.collapse_key, "456789") + + # def test_bad_priority(self): + # with mock.patch("apns2.credentials.init_context"): + # with mock.patch("apns2.client.APNsClient.connect"): + # with mock.patch("apns2.client.APNsClient.send_notification") as s: + # self.assertRaises(APNSUnsupportedPriority, _apns_send, "123", "_" * 2049, priority=24) + # s.assert_has_calls([]) diff --git a/tests/test_apns_models.py b/tests/test_apns_models.py index bb1041a7..ff3a655f 100644 --- a/tests/test_apns_models.py +++ b/tests/test_apns_models.py @@ -1,16 +1,27 @@ +import sys from unittest import mock -from apns2.client import NotificationPriority -from apns2.errors import BadTopic, PayloadTooLarge, Unregistered -from django.conf import settings -from django.test import TestCase, override_settings +import pytest -from push_notifications.exceptions import APNSError -from push_notifications.models import APNSDevice +try: + from apns2.client import NotificationPriority + from apns2.errors import BadTopic, PayloadTooLarge, Unregistered + from django.conf import settings + from django.test import TestCase, override_settings -class APNSModelTestCase(TestCase): + from push_notifications.exceptions import APNSError + from push_notifications.models import APNSDevice +except AttributeError: + # skipping because apns2 is not supported on python 3.10 + # it uses hyper that imports from collections which were changed in 3.10 + # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" + if sys.version_info >= (3, 10): + pytest.skip(allow_module_level=True) + else: + raise +class APNSModelTestCase(TestCase): def _create_devices(self, devices): for device in devices: APNSDevice.objects.create(registration_id=device) @@ -20,9 +31,9 @@ def test_apns_send_bulk_message(self): self._create_devices(["abc", "def"]) # legacy conf manager requires a value - settings.PUSH_NOTIFICATIONS_SETTINGS.update({ - "APNS_CERTIFICATE": "/path/to/apns/certificate.pem" - }) + settings.PUSH_NOTIFICATIONS_SETTINGS.update( + {"APNS_CERTIFICATE": "/path/to/apns/certificate.pem"} + ) with mock.patch("apns2.credentials.init_context"): with mock.patch("apns2.client.APNsClient.connect"): @@ -42,7 +53,8 @@ def test_apns_send_message_extra(self): with mock.patch("apns2.client.APNsClient.connect"): with mock.patch("apns2.client.APNsClient.send_notification") as s: APNSDevice.objects.get().send_message( - "Hello world", expiration=2, priority=5, extra={"foo": "bar"}) + "Hello world", expiration=2, priority=5, extra={"foo": "bar"} + ) args, kargs = s.call_args self.assertEqual(args[0], "abc") self.assertEqual(args[1].alert, "Hello world") @@ -91,9 +103,13 @@ def test_apns_send_message_to_several_devices_with_error(self): self.assertEqual(ae.exception.status, expected_exceptions_statuses[idx]) if idx == 2: - self.assertFalse(APNSDevice.objects.get(registration_id=token).active) + self.assertFalse( + APNSDevice.objects.get(registration_id=token).active + ) else: - self.assertTrue(APNSDevice.objects.get(registration_id=token).active) + self.assertTrue( + APNSDevice.objects.get(registration_id=token).active + ) def test_apns_send_message_to_bulk_devices_with_error(self): # these errors are device specific, device.active will be set false @@ -108,6 +124,10 @@ def test_apns_send_message_to_bulk_devices_with_error(self): for idx, token in enumerate(devices): if idx == 2: - self.assertFalse(APNSDevice.objects.get(registration_id=token).active) + self.assertFalse( + APNSDevice.objects.get(registration_id=token).active + ) else: - self.assertTrue(APNSDevice.objects.get(registration_id=token).active) + self.assertTrue( + APNSDevice.objects.get(registration_id=token).active + ) diff --git a/tests/test_apns_push_payload.py b/tests/test_apns_push_payload.py index dba72b00..a45ca693 100644 --- a/tests/test_apns_push_payload.py +++ b/tests/test_apns_push_payload.py @@ -1,11 +1,21 @@ +import sys from unittest import mock -from apns2.client import NotificationPriority +import pytest from django.test import TestCase -from push_notifications.apns import _apns_send -from push_notifications.exceptions import APNSUnsupportedPriority - +try: + from apns2.client import NotificationPriority + from push_notifications.apns import _apns_send + from push_notifications.exceptions import APNSUnsupportedPriority +except AttributeError: + # skipping because apns2 is not supported on python 3.10 + # it uses hyper that imports from collections which were changed in 3.10 + # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" + if sys.version_info >= (3, 10): + pytest.skip(allow_module_level=True) + else: + raise class APNSPushPayloadTest(TestCase): diff --git a/tox.ini b/tox.ini index 7f3f8826..43936683 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ usedevelop = true envlist = py{37,38,39}-dj{22,32} py{38,39}-dj{40,405} + py311-dj42 flake8 [gh-actions] @@ -11,6 +12,7 @@ python = 3.7: py37 3.8: py38 3.9: py39, flake8 + 3.11: py311 [gh-actions:env] DJANGO = @@ -18,6 +20,7 @@ DJANGO = 3.2: dj32 4.0: dj40 4.0.5: dj405 + 4.2: dj42 [testenv] usedevelop = true @@ -40,6 +43,8 @@ deps = dj32: Django>=3.2,<3.3 dj40: Django>=4.0,<4.0.5 dj405: Django>=4.0.5,<4.1 + dj42: Django>=4.2,<4.3 + dj42: aioapns>=3.1,<3.2 [testenv:flake8] commands = flake8 --exit-zero From 02da89ba861b8f35b41d09ad3a66002581239dae Mon Sep 17 00:00:00 2001 From: pom Date: Fri, 8 Dec 2023 09:14:19 +0100 Subject: [PATCH 11/31] remove print statements --- push_notifications/apns_async.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index 398dc447..6484634a 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -127,8 +127,6 @@ def send_message( self, request: NotificationRequest, ): - print("Sending {} to {}".format(request, request.device_token)) - loop = asyncio.get_event_loop() res1 = self.client.send_notification(request) res = loop.run_until_complete(res1) @@ -196,7 +194,6 @@ def _create_client( if creds is None: creds = self._get_credentials(application_id) - print(creds) client = APNs( **asdict(creds), topic=topic, # Bundle ID @@ -260,7 +257,7 @@ def apns_send_message( apns_service = APNsService( application_id=application_id, creds=creds, topic=topic ) - print(badge) + request = apns_service._create_notification_request_from_args( registration_id, alert, @@ -280,7 +277,6 @@ def apns_send_message( registration_id=registration_id ).update(active=False) raise APNSServerError(status=res.description) - print(res) except ConnectionError as e: raise APNSServerError(status=e.__class__.__name__) From fa1d501161c5577a2920019589524ae520711828 Mon Sep 17 00:00:00 2001 From: pom Date: Mon, 18 Mar 2024 08:44:26 +0100 Subject: [PATCH 12/31] document APNS_ASYNC installation in README --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index abcf1cc2..26c43bb9 100644 --- a/README.rst +++ b/README.rst @@ -38,6 +38,7 @@ Dependencies step does not need to occur on the application server. - For Apple Push (APNS), apns2 0.3+ is required (optional). - For FCM, firebase-admin 6.2+ is required (optional). +- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional), installed aioapns overrides apns2. Setup ----- @@ -45,7 +46,7 @@ You can install the library directly from pypi using pip: .. code-block:: shell - $ pip install django-push-notifications[WP,APNS,FCM] + $ pip install django-push-notifications[WP,APNS_ASYNC] Edit your settings.py file: From 645eabe2a470f905362beebf1c468721d19affbd Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 14 Feb 2024 04:03:30 +0100 Subject: [PATCH 13/31] Add FCM v1 API (#702) * Add FCM v1 API * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add Unit Tests for GCMDeviceAdmin and fix FCM v1 tests * fixes admin test for python 3.6 --------- Co-authored-by: Tim Jahn Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.rst | 2 +- docs/FCM.rst | 2 +- push_notifications/conf/__init__.py | 2 +- push_notifications/conf/app.py | 1 - push_notifications/gcm.py | 2 +- push_notifications/models.py | 4 +-- push_notifications/settings.py | 7 +++- setup.cfg | 2 +- setup.py | 3 +- tests/test_admin.py | 6 ++-- tests/test_gcm_push_payload.py | 4 +-- tests/test_legacy_config.py | 1 - tests/test_models.py | 50 ++++++++++++++--------------- tox.ini | 2 +- 14 files changed, 45 insertions(+), 43 deletions(-) diff --git a/README.rst b/README.rst index 26c43bb9..a90d12b7 100644 --- a/README.rst +++ b/README.rst @@ -46,7 +46,7 @@ You can install the library directly from pypi using pip: .. code-block:: shell - $ pip install django-push-notifications[WP,APNS_ASYNC] + $ pip install django-push-notifications[WP,APNS,FCM] Edit your settings.py file: diff --git a/docs/FCM.rst b/docs/FCM.rst index ffc7d12d..7944c255 100644 --- a/docs/FCM.rst +++ b/docs/FCM.rst @@ -18,7 +18,7 @@ Initialize the firebase admin in your ``settings.py`` file. .. code-block:: python # Import the firebase service - import firebase_admin + from firebase_admin import auth # Initialize the default app default_app = firebase_admin.initialize_app() diff --git a/push_notifications/conf/__init__.py b/push_notifications/conf/__init__.py index 4e798ccd..e11f28fb 100644 --- a/push_notifications/conf/__init__.py +++ b/push_notifications/conf/__init__.py @@ -1,9 +1,9 @@ from django.utils.module_loading import import_string +from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS # noqa: I001 from .app import AppConfig # noqa: F401 from .appmodel import AppModelConfig # noqa: F401 from .legacy import LegacyConfig # noqa: F401 -from ..settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS # noqa: I001 manager = None diff --git a/push_notifications/conf/app.py b/push_notifications/conf/app.py index da91e3be..a70aa1f5 100644 --- a/push_notifications/conf/app.py +++ b/push_notifications/conf/app.py @@ -25,7 +25,6 @@ PLATFORMS = [ "APNS", "FCM", - "GCM", "WNS", "WP", ] diff --git a/push_notifications/gcm.py b/push_notifications/gcm.py index 923322e9..ea6d7a33 100644 --- a/push_notifications/gcm.py +++ b/push_notifications/gcm.py @@ -181,7 +181,7 @@ def send_message( messages = [ _prepare_message(message, token) for token in chunk ] - responses = messaging.send_each(messages, dry_run=dry_run, app=app).responses + responses = messaging.send_all(messages, dry_run=dry_run, app=app).responses ret.extend(responses) _deactivate_devices_with_error_results(registration_ids, ret) return messaging.BatchResponse(ret) diff --git a/push_notifications/models.py b/push_notifications/models.py index 2f49ff8d..57f9d14e 100644 --- a/push_notifications/models.py +++ b/push_notifications/models.py @@ -1,7 +1,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from firebase_admin import messaging from .fields import HexIntegerField +from .gcm import dict_to_fcm_message from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS @@ -58,7 +60,6 @@ def get_queryset(self): class GCMDeviceQuerySet(models.query.QuerySet): def send_message(self, message, **kwargs): if self.exists(): - from .gcm import dict_to_fcm_message, messaging from .gcm import send_message as fcm_send_message if not isinstance(message, messaging.Message): @@ -107,7 +108,6 @@ class Meta: verbose_name = _("FCM device") def send_message(self, message, **kwargs): - from .gcm import dict_to_fcm_message, messaging from .gcm import send_message as fcm_send_message # GCM is not supported. diff --git a/push_notifications/settings.py b/push_notifications/settings.py index 742063d5..5fba8b33 100644 --- a/push_notifications/settings.py +++ b/push_notifications/settings.py @@ -9,7 +9,7 @@ # FCM PUSH_NOTIFICATIONS_SETTINGS.setdefault("FIREBASE_APP", None) -PUSH_NOTIFICATIONS_SETTINGS.setdefault("FCM_MAX_RECIPIENTS", 500) +PUSH_NOTIFICATIONS_SETTINGS.setdefault("FCM_MAX_RECIPIENTS", 1000) # APNS if settings.DEBUG: @@ -27,6 +27,11 @@ ) # WP (WebPush) + +PUSH_NOTIFICATIONS_SETTINGS.setdefault( + "FCM_POST_URL", "https://fcm.googleapis.com/fcm/send" +) + PUSH_NOTIFICATIONS_SETTINGS.setdefault("WP_POST_URL", { "CHROME": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], "OPERA": PUSH_NOTIFICATIONS_SETTINGS["FCM_POST_URL"], diff --git a/setup.cfg b/setup.cfg index e4f0c7be..fdbb780b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,7 +42,7 @@ APNS = WP = pywebpush>=1.3.0 -FCM = firebase-admin>=6.2 +FCM = firebase-admin>=5,<6 APNS_ASYNC = aioapns>=3.1 diff --git a/setup.py b/setup.py index 759840f2..2d4d4992 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,7 @@ #!/usr/bin/env python from pathlib import Path - from setuptools import setup -from pathlib import Path + this_directory = Path(__file__).parent long_description = (this_directory / "README.rst").read_text() diff --git a/tests/test_admin.py b/tests/test_admin.py index a1ca904c..65378801 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -24,7 +24,7 @@ def test_send_bulk_messages_action(self): admin.message_user = mock.Mock() with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: admin.send_messages(request, queryset, bulk=True) @@ -61,7 +61,7 @@ def test_send_single_message_action(self): admin.message_user = mock.Mock() with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: admin.send_messages(request, queryset, bulk=False) @@ -102,7 +102,7 @@ def test_send_bulk_messages_action_fail(self): ) with mock.patch( - "firebase_admin.messaging.send_each", return_value=response + "firebase_admin.messaging.send_all", return_value=response ) as p: admin.send_messages(request, queryset, bulk=True) diff --git a/tests/test_gcm_push_payload.py b/tests/test_gcm_push_payload.py index 8bf1eb48..657cdc9d 100644 --- a/tests/test_gcm_push_payload.py +++ b/tests/test_gcm_push_payload.py @@ -12,7 +12,7 @@ class GCMPushPayloadTest(TestCase): def test_fcm_push_payload(self): - with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: + with mock.patch("firebase_admin.messaging.send_all", return_value=FCM_SUCCESS) as p: message = dict_to_fcm_message({"message": "Hello world"}) send_message("abc", message) @@ -37,7 +37,7 @@ def test_fcm_push_payload(self): self.assertEqual(message.android.notification.body, "Hello world") def test_fcm_push_payload_many(self): - with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: + with mock.patch("firebase_admin.messaging.send_all", return_value=FCM_SUCCESS) as p: message = dict_to_fcm_message({"message": "Hello world"}) send_message(["abc", "123"], message) diff --git a/tests/test_legacy_config.py b/tests/test_legacy_config.py index f441d425..0cd91f24 100644 --- a/tests/test_legacy_config.py +++ b/tests/test_legacy_config.py @@ -3,7 +3,6 @@ from django.test import TestCase from push_notifications.conf import get_manager -from push_notifications.conf.legacy import LegacyConfig from push_notifications.exceptions import WebPushError from push_notifications.webpush import webpush_send_message diff --git a/tests/test_models.py b/tests/test_models.py index d7323dc6..f7a94edf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -38,7 +38,7 @@ def test_can_create_save_device(self): def test_fcm_send_message(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world") @@ -65,7 +65,7 @@ def test_fcm_send_message(self): def test_fcm_send_message_with_fcm_message(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -99,7 +99,7 @@ def test_fcm_send_message_with_fcm_message(self): def test_fcm_send_message_extra_data(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", extra={"foo": "bar"}) @@ -125,7 +125,7 @@ def test_fcm_send_message_extra_data(self): def test_fcm_send_message_extra_options(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", collapse_key="test_key", foo="bar") @@ -152,7 +152,7 @@ def test_fcm_send_message_extra_options(self): def test_fcm_send_message_extra_notification(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", extra={"icon": "test_icon"}, title="test") @@ -180,7 +180,7 @@ def test_fcm_send_message_extra_notification(self): def test_fcm_send_message_extra_options_and_notification_and_data(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS ) as p: device.send_message( "Hello world", @@ -215,7 +215,7 @@ def test_fcm_send_message_to_multiple_devices(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world") @@ -245,7 +245,7 @@ def test_fcm_send_message_to_multiple_devices_fcm_message(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -283,7 +283,7 @@ def test_gcm_send_message_does_not_send(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -299,7 +299,7 @@ def test_gcm_send_multiple_message_does_not_send(self): self._create_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -318,7 +318,7 @@ def test_fcm_send_message_active_devices(self): GCMDevice.objects.create(registration_id="xyz", active=False, cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world") @@ -344,7 +344,7 @@ def test_fcm_send_message_collapse_to_multiple_devices(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key") @@ -386,7 +386,7 @@ def test_fcm_send_message_to_single_device_with_error(self): [SendResponse(resp={"name": "..."}, exception=error)] ) with mock.patch( - "firebase_admin.messaging.send_each", return_value=return_value + "firebase_admin.messaging.send_all", return_value=return_value ): device = GCMDevice.objects.get(registration_id=devices[index]) device.send_message("Hello World!") @@ -399,7 +399,7 @@ def test_fcm_send_message_to_single_device_with_error_mismatch(self): [SendResponse(resp={"name": "..."}, exception=OSError())] ) with mock.patch( - "firebase_admin.messaging.send_each", + "firebase_admin.messaging.send_all", return_value=return_value ): # these errors are not device specific, device is not deactivated @@ -417,7 +417,7 @@ def test_fcm_send_message_to_multiple_devices_with_error(self): SendResponse(resp={"name": "..."}, exception=InvalidArgumentError("Invalid registration")), ]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=return_value + "firebase_admin.messaging.send_all", return_value=return_value ): GCMDevice.objects.all().send_message("Hello World") self.assertFalse(GCMDevice.objects.get(registration_id="abc").active) @@ -436,7 +436,7 @@ def test_fcm_send_message_to_multiple_devices_with_error_b(self): ]) with mock.patch( - "firebase_admin.messaging.send_each", return_value=return_value + "firebase_admin.messaging.send_all", return_value=return_value ): GCMDevice.objects.all().send_message("Hello World") self.assertTrue(GCMDevice.objects.get(registration_id="abc").active) @@ -448,7 +448,7 @@ def test_fcm_send_message_with_no_reg_ids(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_each", + "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.filter(registration_id="xyz").send_message("Hello World") @@ -459,17 +459,17 @@ def test_fcm_send_message_with_no_reg_ids(self): return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()] -<<<<<<< HEAD message = dict_to_fcm_message({"message": "Hello World"}) send_bulk_message(reg_ids, message) p.assert_called_once() -======= - send_bulk_message(reg_ids, {"message": "Hello World"}, "FCM") - p.assert_called_once_with( - ["abc", "abc1"], {"message": "Hello World"}, cloud_type="FCM", - application_id=None - ) ->>>>>>> 34d6c54 ([pre-commit.ci] pre-commit autoupdate (#669)) + with mock.patch( + "firebase_admin.messaging.send_all", + return_value=responses.FCM_SUCCESS_MULTIPLE + ) as p: + reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()] + message = dict_to_fcm_message({"message": "Hello World"}) + send_bulk_message(reg_ids, message) + p.assert_called_once() def test_can_save_wsn_device(self): device = GCMDevice.objects.create(registration_id="a valid registration id") diff --git a/tox.ini b/tox.ini index 43936683..2d1234ba 100644 --- a/tox.ini +++ b/tox.ini @@ -38,7 +38,7 @@ deps = pytest-django pywebpush djangorestframework - firebase-admin>=6.2 + firebase-admin>=5,<6 dj22: Django>=2.2,<3.0 dj32: Django>=3.2,<3.3 dj40: Django>=4.0,<4.0.5 From fe35a5d1dde052bb0475c37a7b73cb36dd3f4728 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 27 Feb 2024 13:20:17 +0100 Subject: [PATCH 14/31] make firebase-admin an optional dependency (#707) Co-authored-by: Tim Jahn --- docs/FCM.rst | 2 +- push_notifications/models.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/FCM.rst b/docs/FCM.rst index 7944c255..ffc7d12d 100644 --- a/docs/FCM.rst +++ b/docs/FCM.rst @@ -18,7 +18,7 @@ Initialize the firebase admin in your ``settings.py`` file. .. code-block:: python # Import the firebase service - from firebase_admin import auth + import firebase_admin # Initialize the default app default_app = firebase_admin.initialize_app() diff --git a/push_notifications/models.py b/push_notifications/models.py index 57f9d14e..094400cf 100644 --- a/push_notifications/models.py +++ b/push_notifications/models.py @@ -1,9 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from firebase_admin import messaging from .fields import HexIntegerField -from .gcm import dict_to_fcm_message from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS @@ -60,6 +58,9 @@ def get_queryset(self): class GCMDeviceQuerySet(models.query.QuerySet): def send_message(self, message, **kwargs): if self.exists(): + from firebase_admin import messaging + + from .gcm import dict_to_fcm_message from .gcm import send_message as fcm_send_message if not isinstance(message, messaging.Message): @@ -108,6 +109,9 @@ class Meta: verbose_name = _("FCM device") def send_message(self, message, **kwargs): + from firebase_admin import messaging + + from .gcm import dict_to_fcm_message from .gcm import send_message as fcm_send_message # GCM is not supported. From 3ba6e7e70ada662dd3b57a66f948a86ad2fbb444 Mon Sep 17 00:00:00 2001 From: sevdog Date: Fri, 23 Feb 2024 15:55:59 +0100 Subject: [PATCH 15/31] Do not import fcm module at top level in models.py --- push_notifications/models.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/push_notifications/models.py b/push_notifications/models.py index 094400cf..2f49ff8d 100644 --- a/push_notifications/models.py +++ b/push_notifications/models.py @@ -58,9 +58,7 @@ def get_queryset(self): class GCMDeviceQuerySet(models.query.QuerySet): def send_message(self, message, **kwargs): if self.exists(): - from firebase_admin import messaging - - from .gcm import dict_to_fcm_message + from .gcm import dict_to_fcm_message, messaging from .gcm import send_message as fcm_send_message if not isinstance(message, messaging.Message): @@ -109,9 +107,7 @@ class Meta: verbose_name = _("FCM device") def send_message(self, message, **kwargs): - from firebase_admin import messaging - - from .gcm import dict_to_fcm_message + from .gcm import dict_to_fcm_message, messaging from .gcm import send_message as fcm_send_message # GCM is not supported. From caba137d751d809853af486193d838b4b46bb6e9 Mon Sep 17 00:00:00 2001 From: pom Date: Mon, 18 Mar 2024 11:54:44 +0100 Subject: [PATCH 16/31] fix issue with overwriting default, add test case --- push_notifications/apns_async.py | 12 ++++++---- tests/test_apns_async_models.py | 39 +++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index 6484634a..426083bf 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -159,13 +159,15 @@ def _create_notification_request_from_args( if isinstance(alert, Alert): alert = alert.asDict() + notification_request_kwargs_out = notification_request_kwargs.copy() + if expiration is not None: - notification_request_kwargs["time_to_live"] = expiration - int(time.time()) + notification_request_kwargs_out["time_to_live"] = expiration - int(time.time()) if priority is not None: - notification_request_kwargs["priority"] = priority + notification_request_kwargs_out["priority"] = priority if collapse_id is not None: - notification_request_kwargs["collapse_key"] = collapse_id + notification_request_kwargs_out["collapse_key"] = collapse_id request = NotificationRequest( device_token=registration_id, @@ -180,7 +182,7 @@ def _create_notification_request_from_args( **extra, **message_kwargs, }, - **notification_request_kwargs, + **notification_request_kwargs_out, ) return request @@ -219,7 +221,7 @@ def _get_credentials(self, application_id): return TokenCredentials(key=keyPath, key_id=keyId, team_id=teamId) -## Public interface +# Public interface def apns_send_message( diff --git a/tests/test_apns_async_models.py b/tests/test_apns_async_models.py index 50addd2f..3299b636 100644 --- a/tests/test_apns_async_models.py +++ b/tests/test_apns_async_models.py @@ -2,15 +2,16 @@ import time from unittest import mock +import pytest from django.conf import settings from django.test import TestCase, override_settings -import pytest + try: + from aioapns.common import NotificationResult + from push_notifications.exceptions import APNSError from push_notifications.models import APNSDevice - - from aioapns.common import NotificationResult except ModuleNotFoundError: # skipping because apns2 is not supported on python 3.10 # it uses hyper that imports from collections which were changed in 3.10 @@ -164,3 +165,35 @@ def test_apns_send_message_to_bulk_devices_with_error(self, mock_apns): self.assertFalse(APNSDevice.objects.get(registration_id=token).active) else: self.assertTrue(APNSDevice.objects.get(registration_id=token).active) + + @mock.patch("push_notifications.apns_async.APNs", autospec=True) + def test_apns_send_messages_different_priority(self, mock_apns): + self._create_devices(["abc", "def"]) + device_1 = APNSDevice.objects.get(registration_id="abc") + device_2 = APNSDevice.objects.get(registration_id="def") + + device_1.send_message( + "Hello world 1", + expiration=time.time() + 1, + priority=5, + collapse_id="1", + ) + args_1, _ = mock_apns.return_value.send_notification.call_args + + device_2.send_message("Hello world 2") + args_2, _ = mock_apns.return_value.send_notification.call_args + + req = args_1[0] + self.assertEqual(req.device_token, "abc") + self.assertEqual(req.message["aps"]["alert"], "Hello world 1") + self.assertAlmostEqual(req.time_to_live, 1, places=-1) + self.assertEqual(req.priority, 5) + self.assertEqual(req.collapse_key, "1") + + reg_2 = args_2[0] + self.assertEqual(reg_2.device_token, "def") + self.assertEqual(reg_2.message["aps"]["alert"], "Hello world 2") + self.assertIsNone(reg_2.time_to_live, "No time to live should be specified") + self.assertIsNone(reg_2.priority, "No priority should be specified") + self.assertIsNone(reg_2.collapse_key, "No collapse key should be specified") + From 399c2490c272b1bddaebede93c38fcbba0462609 Mon Sep 17 00:00:00 2001 From: pom Date: Mon, 18 Mar 2024 11:55:05 +0100 Subject: [PATCH 17/31] fix some flake8 issues, improve readme --- README.rst | 4 ++-- tests/test_apns_async_push_payload.py | 21 +++++++++------------ tests/test_apns_push_payload.py | 1 + 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index a90d12b7..241c64ea 100644 --- a/README.rst +++ b/README.rst @@ -37,8 +37,8 @@ Dependencies - For WebPush (WP), pywebpush 1.3.0+ is required (optional). py-vapid 1.3.0+ is required for generating the WebPush private key; however this step does not need to occur on the application server. - For Apple Push (APNS), apns2 0.3+ is required (optional). -- For FCM, firebase-admin 6.2+ is required (optional). -- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional), installed aioapns overrides apns2. +- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional). Installed aioapns overrides apns2 which does not support python 3.10+. +- For FCM, firebase-admin 5+ is required (optional). Setup ----- diff --git a/tests/test_apns_async_push_payload.py b/tests/test_apns_async_push_payload.py index 60160392..4ad64284 100644 --- a/tests/test_apns_async_push_payload.py +++ b/tests/test_apns_async_push_payload.py @@ -5,12 +5,9 @@ import pytest from django.test import TestCase -try: - from push_notifications.apns_async import ( - TokenCredentials, - apns_send_message, - ) +try: + from push_notifications.apns_async import TokenCredentials, apns_send_message except ModuleNotFoundError: # skipping because apns2 is not supported on python 3.10 # it uses hyper that imports from collections which were changed in 3.10 @@ -21,11 +18,10 @@ raise - class APNSAsyncPushPayloadTest(TestCase): @mock.patch("push_notifications.apns_async.APNs", autospec=True) def test_push_payload(self, mock_apns): - _res = apns_send_message( + apns_send_message( "123", "Hello world", creds=TokenCredentials( @@ -50,7 +46,7 @@ def test_push_payload(self, mock_apns): @mock.patch("push_notifications.apns_async.APNs", autospec=True) def test_push_payload_with_thread_id(self, mock_apns): - _res = apns_send_message( + apns_send_message( "123", "Hello world", thread_id="565", @@ -75,7 +71,7 @@ def test_push_payload_with_thread_id(self, mock_apns): @mock.patch("push_notifications.apns_async.APNs", autospec=True) def test_push_payload_with_alert_dict(self, mock_apns): - _res = apns_send_message( + apns_send_message( "123", alert={"title": "t1", "body": "b1"}, sound="chime", @@ -100,7 +96,7 @@ def test_push_payload_with_alert_dict(self, mock_apns): @mock.patch("push_notifications.apns_async.APNs", autospec=True) def test_localised_push_with_empty_body(self, mock_apns): - _res = apns_send_message( + apns_send_message( "123", None, loc_key="TEST_LOC_KEY", @@ -145,7 +141,7 @@ def test_using_extra(self, mock_apns): @mock.patch("push_notifications.apns_async.APNs", autospec=True) def test_collapse_id(self, mock_apns): - _res = apns_send_message( + apns_send_message( "123", "sample", collapse_id="456789", @@ -167,5 +163,6 @@ def test_collapse_id(self, mock_apns): # with mock.patch("apns2.credentials.init_context"): # with mock.patch("apns2.client.APNsClient.connect"): # with mock.patch("apns2.client.APNsClient.send_notification") as s: - # self.assertRaises(APNSUnsupportedPriority, _apns_send, "123", "_" * 2049, priority=24) + # self.assertRaises(APNSUnsupportedPriority, _apns_send, "123", + # "_" * 2049, priority=24) # s.assert_has_calls([]) diff --git a/tests/test_apns_push_payload.py b/tests/test_apns_push_payload.py index a45ca693..8c06a399 100644 --- a/tests/test_apns_push_payload.py +++ b/tests/test_apns_push_payload.py @@ -17,6 +17,7 @@ else: raise + class APNSPushPayloadTest(TestCase): def test_push_payload(self): From 910c7b5499d6f4bdf7888a09b36b3083883c54c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:56:10 +0000 Subject: [PATCH 18/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_apns_async_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_apns_async_models.py b/tests/test_apns_async_models.py index 3299b636..51611f3f 100644 --- a/tests/test_apns_async_models.py +++ b/tests/test_apns_async_models.py @@ -196,4 +196,3 @@ def test_apns_send_messages_different_priority(self, mock_apns): self.assertIsNone(reg_2.time_to_live, "No time to live should be specified") self.assertIsNone(reg_2.priority, "No priority should be specified") self.assertIsNone(reg_2.collapse_key, "No collapse key should be specified") - From 73e54e535deb7cd96a6c62c370b117552881f7b5 Mon Sep 17 00:00:00 2001 From: 50-Course Date: Fri, 22 Mar 2024 09:55:12 +0100 Subject: [PATCH 19/31] ci(python-deps): support `python 3.10` as testing target on CI --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75779548..50b2db55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 From 3a77e6fb020930e6a9e0b8d9b2800b28f3e3ad27 Mon Sep 17 00:00:00 2001 From: 50-Course Date: Fri, 22 Mar 2024 10:21:39 +0100 Subject: [PATCH 20/31] bump `tox` python target version to `py310` --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 2d1234ba..b165b9d8 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ skipsdist = False usedevelop = true envlist = - py{37,38,39}-dj{22,32} - py{38,39}-dj{40,405} + py{36,37,38,39}-dj{22,32} + py{38,39,310}-dj{40,405} py311-dj42 flake8 @@ -12,6 +12,7 @@ python = 3.7: py37 3.8: py38 3.9: py39, flake8 + 3.10: py310 3.11: py311 [gh-actions:env] From 079418ce0308d760aa2300db516a14e4b25da561 Mon Sep 17 00:00:00 2001 From: pomali Date: Wed, 22 May 2024 00:48:11 -0700 Subject: [PATCH 21/31] Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50b2db55..f78e36d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10','3.11'] steps: - uses: actions/checkout@v2 From 7eaec6ff314252c2d1c18c9070ba4657ed34cd63 Mon Sep 17 00:00:00 2001 From: pomali Date: Wed, 22 May 2024 00:48:33 -0700 Subject: [PATCH 22/31] Update test.yml (fix missing space) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f78e36d6..22ac783e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10','3.11'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 From 9733b2210552c6a800c817fc789ac65bb44b24e6 Mon Sep 17 00:00:00 2001 From: pom Date: Wed, 22 May 2024 09:48:54 +0200 Subject: [PATCH 23/31] Add fixes from PR suggestions, update tested environments --- setup.cfg | 1 + tests/test_apns_async_models.py | 2 -- tests/test_apns_models.py | 2 +- tests/test_apns_push_payload.py | 2 +- tox.ini | 7 +++---- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/setup.cfg b/setup.cfg index fdbb780b..47a85a70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP Topic :: System :: Networking diff --git a/tests/test_apns_async_models.py b/tests/test_apns_async_models.py index 51611f3f..291cc01b 100644 --- a/tests/test_apns_async_models.py +++ b/tests/test_apns_async_models.py @@ -40,10 +40,8 @@ def test_apns_send_bulk_message(self, mock_apns): APNSDevice.objects.all().send_message("Hello world", expiration=time.time() + 3) [call1, call2] = mock_apns.return_value.send_notification.call_args_list - print(call1) req1 = call1.args[0] req2 = call2.args[0] - print(dir(req1)) self.assertEqual(req1.device_token, "abc") self.assertEqual(req2.device_token, "def") diff --git a/tests/test_apns_models.py b/tests/test_apns_models.py index ff3a655f..bd15a97d 100644 --- a/tests/test_apns_models.py +++ b/tests/test_apns_models.py @@ -12,7 +12,7 @@ from push_notifications.exceptions import APNSError from push_notifications.models import APNSDevice -except AttributeError: +except (AttributeError, ModuleNotFoundError): # skipping because apns2 is not supported on python 3.10 # it uses hyper that imports from collections which were changed in 3.10 # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" diff --git a/tests/test_apns_push_payload.py b/tests/test_apns_push_payload.py index 8c06a399..450a3025 100644 --- a/tests/test_apns_push_payload.py +++ b/tests/test_apns_push_payload.py @@ -8,7 +8,7 @@ from apns2.client import NotificationPriority from push_notifications.apns import _apns_send from push_notifications.exceptions import APNSUnsupportedPriority -except AttributeError: +except (AttributeError, ModuleNotFoundError): # skipping because apns2 is not supported on python 3.10 # it uses hyper that imports from collections which were changed in 3.10 # and we would get "AttributeError: module 'collections' has no attribute 'MutableMapping'" diff --git a/tox.ini b/tox.ini index b165b9d8..e8afe1de 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,7 @@ skipsdist = False usedevelop = true envlist = py{36,37,38,39}-dj{22,32} - py{38,39,310}-dj{40,405} - py311-dj42 + py{38,39,310,311}-dj{40,405,42} flake8 [gh-actions] @@ -33,7 +32,6 @@ commands = pytest pytest --ds=tests.settings_unique tests/tst_unique.py deps = - apns2 pytest pytest-cov pytest-django @@ -45,7 +43,8 @@ deps = dj40: Django>=4.0,<4.0.5 dj405: Django>=4.0.5,<4.1 dj42: Django>=4.2,<4.3 - dj42: aioapns>=3.1,<3.2 + py{36,37,38,39}: apns2 + py{310,311}: aioapns>=3.1,<3.2 [testenv:flake8] commands = flake8 --exit-zero From 3b0abf62b8436217c4fe2014a7da8444705538b2 Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Sat, 27 Apr 2024 18:25:02 -0400 Subject: [PATCH 24/31] Remove python 3.6 support. (#718) * Remove python 3.6 support. * Remove unsupported code --- .github/workflows/test.yml | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22ac783e..ab63c42f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 diff --git a/tox.ini b/tox.ini index e8afe1de..3a2ead6b 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ skipsdist = False usedevelop = true envlist = - py{36,37,38,39}-dj{22,32} - py{38,39,310,311}-dj{40,405,42} + py{37,38,39}-dj{22,32} + py{38,39}-dj{40,405} flake8 [gh-actions] From 97fdf6ab642e2f5df2992392e5f3142ad2acf360 Mon Sep 17 00:00:00 2001 From: guspix <33852783+guspix@users.noreply.github.com> Date: Sat, 27 Apr 2024 20:50:16 -0400 Subject: [PATCH 25/31] Changed firebase-admin's deprecated send_all method for send_each (#715) --- README.rst | 6 ++--- push_notifications/gcm.py | 2 +- setup.cfg | 2 +- tests/test_admin.py | 6 ++--- tests/test_gcm_push_payload.py | 4 ++-- tests/test_models.py | 42 ++++++++++++++-------------------- tox.ini | 2 +- 7 files changed, 28 insertions(+), 36 deletions(-) diff --git a/README.rst b/README.rst index 241c64ea..26c43bb9 100644 --- a/README.rst +++ b/README.rst @@ -37,8 +37,8 @@ Dependencies - For WebPush (WP), pywebpush 1.3.0+ is required (optional). py-vapid 1.3.0+ is required for generating the WebPush private key; however this step does not need to occur on the application server. - For Apple Push (APNS), apns2 0.3+ is required (optional). -- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional). Installed aioapns overrides apns2 which does not support python 3.10+. -- For FCM, firebase-admin 5+ is required (optional). +- For FCM, firebase-admin 6.2+ is required (optional). +- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional), installed aioapns overrides apns2. Setup ----- @@ -46,7 +46,7 @@ You can install the library directly from pypi using pip: .. code-block:: shell - $ pip install django-push-notifications[WP,APNS,FCM] + $ pip install django-push-notifications[WP,APNS_ASYNC] Edit your settings.py file: diff --git a/push_notifications/gcm.py b/push_notifications/gcm.py index ea6d7a33..923322e9 100644 --- a/push_notifications/gcm.py +++ b/push_notifications/gcm.py @@ -181,7 +181,7 @@ def send_message( messages = [ _prepare_message(message, token) for token in chunk ] - responses = messaging.send_all(messages, dry_run=dry_run, app=app).responses + responses = messaging.send_each(messages, dry_run=dry_run, app=app).responses ret.extend(responses) _deactivate_devices_with_error_results(registration_ids, ret) return messaging.BatchResponse(ret) diff --git a/setup.cfg b/setup.cfg index 47a85a70..b185cd71 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,7 @@ APNS = WP = pywebpush>=1.3.0 -FCM = firebase-admin>=5,<6 +FCM = firebase-admin>=6.2 APNS_ASYNC = aioapns>=3.1 diff --git a/tests/test_admin.py b/tests/test_admin.py index 65378801..a1ca904c 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -24,7 +24,7 @@ def test_send_bulk_messages_action(self): admin.message_user = mock.Mock() with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: admin.send_messages(request, queryset, bulk=True) @@ -61,7 +61,7 @@ def test_send_single_message_action(self): admin.message_user = mock.Mock() with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: admin.send_messages(request, queryset, bulk=False) @@ -102,7 +102,7 @@ def test_send_bulk_messages_action_fail(self): ) with mock.patch( - "firebase_admin.messaging.send_all", return_value=response + "firebase_admin.messaging.send_each", return_value=response ) as p: admin.send_messages(request, queryset, bulk=True) diff --git a/tests/test_gcm_push_payload.py b/tests/test_gcm_push_payload.py index 657cdc9d..8bf1eb48 100644 --- a/tests/test_gcm_push_payload.py +++ b/tests/test_gcm_push_payload.py @@ -12,7 +12,7 @@ class GCMPushPayloadTest(TestCase): def test_fcm_push_payload(self): - with mock.patch("firebase_admin.messaging.send_all", return_value=FCM_SUCCESS) as p: + with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: message = dict_to_fcm_message({"message": "Hello world"}) send_message("abc", message) @@ -37,7 +37,7 @@ def test_fcm_push_payload(self): self.assertEqual(message.android.notification.body, "Hello world") def test_fcm_push_payload_many(self): - with mock.patch("firebase_admin.messaging.send_all", return_value=FCM_SUCCESS) as p: + with mock.patch("firebase_admin.messaging.send_each", return_value=FCM_SUCCESS) as p: message = dict_to_fcm_message({"message": "Hello world"}) send_message(["abc", "123"], message) diff --git a/tests/test_models.py b/tests/test_models.py index f7a94edf..2575fc4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -38,7 +38,7 @@ def test_can_create_save_device(self): def test_fcm_send_message(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world") @@ -65,7 +65,7 @@ def test_fcm_send_message(self): def test_fcm_send_message_with_fcm_message(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -99,7 +99,7 @@ def test_fcm_send_message_with_fcm_message(self): def test_fcm_send_message_extra_data(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", extra={"foo": "bar"}) @@ -125,7 +125,7 @@ def test_fcm_send_message_extra_data(self): def test_fcm_send_message_extra_options(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", collapse_key="test_key", foo="bar") @@ -152,7 +152,7 @@ def test_fcm_send_message_extra_options(self): def test_fcm_send_message_extra_notification(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: device.send_message("Hello world", extra={"icon": "test_icon"}, title="test") @@ -180,7 +180,7 @@ def test_fcm_send_message_extra_notification(self): def test_fcm_send_message_extra_options_and_notification_and_data(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS ) as p: device.send_message( "Hello world", @@ -215,7 +215,7 @@ def test_fcm_send_message_to_multiple_devices(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world") @@ -245,7 +245,7 @@ def test_fcm_send_message_to_multiple_devices_fcm_message(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -283,7 +283,7 @@ def test_gcm_send_message_does_not_send(self): device = GCMDevice.objects.create(registration_id="abc", cloud_message_type="GCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -299,7 +299,7 @@ def test_gcm_send_multiple_message_does_not_send(self): self._create_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: message_to_send = messaging.Message( notification=messaging.Notification( @@ -318,7 +318,7 @@ def test_fcm_send_message_active_devices(self): GCMDevice.objects.create(registration_id="xyz", active=False, cloud_message_type="FCM") with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world") @@ -344,7 +344,7 @@ def test_fcm_send_message_collapse_to_multiple_devices(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=responses.FCM_SUCCESS_MULTIPLE + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.all().send_message("Hello world", collapse_key="test_key") @@ -386,7 +386,7 @@ def test_fcm_send_message_to_single_device_with_error(self): [SendResponse(resp={"name": "..."}, exception=error)] ) with mock.patch( - "firebase_admin.messaging.send_all", return_value=return_value + "firebase_admin.messaging.send_each", return_value=return_value ): device = GCMDevice.objects.get(registration_id=devices[index]) device.send_message("Hello World!") @@ -399,7 +399,7 @@ def test_fcm_send_message_to_single_device_with_error_mismatch(self): [SendResponse(resp={"name": "..."}, exception=OSError())] ) with mock.patch( - "firebase_admin.messaging.send_all", + "firebase_admin.messaging.send_each", return_value=return_value ): # these errors are not device specific, device is not deactivated @@ -417,7 +417,7 @@ def test_fcm_send_message_to_multiple_devices_with_error(self): SendResponse(resp={"name": "..."}, exception=InvalidArgumentError("Invalid registration")), ]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=return_value + "firebase_admin.messaging.send_each", return_value=return_value ): GCMDevice.objects.all().send_message("Hello World") self.assertFalse(GCMDevice.objects.get(registration_id="abc").active) @@ -436,7 +436,7 @@ def test_fcm_send_message_to_multiple_devices_with_error_b(self): ]) with mock.patch( - "firebase_admin.messaging.send_all", return_value=return_value + "firebase_admin.messaging.send_each", return_value=return_value ): GCMDevice.objects.all().send_message("Hello World") self.assertTrue(GCMDevice.objects.get(registration_id="abc").active) @@ -448,7 +448,7 @@ def test_fcm_send_message_with_no_reg_ids(self): self._create_fcm_devices(["abc", "abc1"]) with mock.patch( - "firebase_admin.messaging.send_all", + "firebase_admin.messaging.send_each", return_value=responses.FCM_SUCCESS_MULTIPLE ) as p: GCMDevice.objects.filter(registration_id="xyz").send_message("Hello World") @@ -462,14 +462,6 @@ def test_fcm_send_message_with_no_reg_ids(self): message = dict_to_fcm_message({"message": "Hello World"}) send_bulk_message(reg_ids, message) p.assert_called_once() - with mock.patch( - "firebase_admin.messaging.send_all", - return_value=responses.FCM_SUCCESS_MULTIPLE - ) as p: - reg_ids = [obj.registration_id for obj in GCMDevice.objects.all()] - message = dict_to_fcm_message({"message": "Hello World"}) - send_bulk_message(reg_ids, message) - p.assert_called_once() def test_can_save_wsn_device(self): device = GCMDevice.objects.create(registration_id="a valid registration id") diff --git a/tox.ini b/tox.ini index 3a2ead6b..f981e151 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ deps = pytest-django pywebpush djangorestframework - firebase-admin>=5,<6 + firebase-admin>=6.2 dj22: Django>=2.2,<3.0 dj32: Django>=3.2,<3.3 dj40: Django>=4.0,<4.0.5 From d259c8f9caf6e25da4836227d5987e41b8d00acd Mon Sep 17 00:00:00 2001 From: pom Date: Wed, 22 May 2024 13:40:53 +0200 Subject: [PATCH 26/31] add types --- push_notifications/apns_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index 426083bf..dd448dea 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import asdict, dataclass import time -from typing import Union +from typing import Dict, Union from aioapns import APNs, NotificationRequest, ConnectionError @@ -313,7 +313,7 @@ def apns_send_bulk_message( """ topic = get_manager().get_apns_topic(application_id) - results = {} + results: Dict[str, str] = {} inactive_tokens = [] apns_service = APNsService(application_id=application_id, creds=creds, topic=topic) for registration_id in registration_ids: From 96b946a1dafc46c8031b4098f7c9b1b531ec1a28 Mon Sep 17 00:00:00 2001 From: pom Date: Thu, 23 May 2024 12:22:02 +0200 Subject: [PATCH 27/31] add err_func to enable processing errors from aioapns.APNs.send_message, add ruff formatting config --- push_notifications/apns_async.py | 41 ++++++++++++++++++++++---------- pyproject.toml | 3 +++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index dd448dea..ccf276ab 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -1,14 +1,17 @@ import asyncio from dataclasses import asdict, dataclass import time -from typing import Dict, Union +from typing import Awaitable, Callable, Dict, Optional, Union -from aioapns import APNs, NotificationRequest, ConnectionError +from aioapns import APNs, NotificationRequest, ConnectionError, NotificationResult from . import models from .conf import get_manager from .exceptions import APNSServerError +ErrFunc = Optional[Callable[[NotificationRequest, NotificationResult], Awaitable[None]]] +"""function to proces errors from aioapns send_message""" + class NotSet: def __init__(self): @@ -111,7 +114,11 @@ class APNsService: __slots__ = ("client",) def __init__( - self, application_id: str = None, creds: Credentials = None, topic: str = None + self, + application_id: str = None, + creds: Credentials = None, + topic: str = None, + err_func: ErrFunc = None, ): try: loop = asyncio.get_event_loop() @@ -120,7 +127,7 @@ def __init__( asyncio.set_event_loop(loop) self.client = self._create_client( - creds=creds, application_id=application_id, topic=topic + creds=creds, application_id=application_id, topic=topic, err_func=err_func ) def send_message( @@ -162,7 +169,9 @@ def _create_notification_request_from_args( notification_request_kwargs_out = notification_request_kwargs.copy() if expiration is not None: - notification_request_kwargs_out["time_to_live"] = expiration - int(time.time()) + notification_request_kwargs_out["time_to_live"] = expiration - int( + time.time() + ) if priority is not None: notification_request_kwargs_out["priority"] = priority @@ -188,7 +197,11 @@ def _create_notification_request_from_args( return request def _create_client( - self, creds: Credentials = None, application_id: str = None, topic=None + self, + creds: Credentials = None, + application_id: str = None, + topic=None, + err_func: ErrFunc = None, ) -> APNs: use_sandbox = get_manager().get_apns_use_sandbox(application_id) if topic is None: @@ -200,6 +213,7 @@ def _create_client( **asdict(creds), topic=topic, # Bundle ID use_sandbox=use_sandbox, + err_func=err_func, ) return client @@ -238,6 +252,7 @@ def apns_send_message( loc_key: str = None, priority: int = None, collapse_id: str = None, + err_func: ErrFunc = None, ): """ Sends an APNS notification to a single registration_id. @@ -257,7 +272,7 @@ def apns_send_message( try: apns_service = APNsService( - application_id=application_id, creds=creds, topic=topic + application_id=application_id, creds=creds, topic=topic, err_func=err_func ) request = apns_service._create_notification_request_from_args( @@ -297,6 +312,7 @@ def apns_send_bulk_message( loc_key: str = None, priority: int = None, collapse_id: str = None, + err_func: ErrFunc = None, ): """ Sends an APNS notification to one or more registration_ids. @@ -315,9 +331,10 @@ def apns_send_bulk_message( topic = get_manager().get_apns_topic(application_id) results: Dict[str, str] = {} inactive_tokens = [] - apns_service = APNsService(application_id=application_id, creds=creds, topic=topic) + apns_service = APNsService( + application_id=application_id, creds=creds, topic=topic, err_func=err_func + ) for registration_id in registration_ids: - request = apns_service._create_notification_request_from_args( registration_id, alert, @@ -331,10 +348,10 @@ def apns_send_bulk_message( collapse_id=collapse_id, ) - result = apns_service.send_message( - request + result = apns_service.send_message(request) + results[registration_id] = ( + "Success" if result.is_successful else result.description ) - results[registration_id] = "Success" if result.is_successful else result.description if not result.is_successful and result.description == "Unregistered": inactive_tokens.append(registration_id) diff --git a/pyproject.toml b/pyproject.toml index 0bfb42bd..76037617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,6 @@ requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] [tool.pytest.ini_options] minversion = "6.0" addopts = "--cov push_notifications --cov-append --cov-branch --cov-report term-missing --cov-report=xml" + +[tool.ruff.format] +indent-style = "tab" From f2e7bd15ee0c58d1483be2511d81b2803bec63ea Mon Sep 17 00:00:00 2001 From: pom Date: Thu, 23 May 2024 12:48:35 +0200 Subject: [PATCH 28/31] fix extras APNS_ASYNC -> apns-async to conform with Core metadata specification https://packaging.python.org/en/latest/specifications/core-metadata/#provides-extra-multiple-use --- README.rst | 4 ++-- setup.cfg | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 26c43bb9..81650929 100644 --- a/README.rst +++ b/README.rst @@ -37,8 +37,8 @@ Dependencies - For WebPush (WP), pywebpush 1.3.0+ is required (optional). py-vapid 1.3.0+ is required for generating the WebPush private key; however this step does not need to occur on the application server. - For Apple Push (APNS), apns2 0.3+ is required (optional). +- For Apple Push (apns-async) using async, aioapns 3.1+ is required (optional). Installed aioapns overrides apns2 which does not support python 3.10+. - For FCM, firebase-admin 6.2+ is required (optional). -- For Apple Push (APNS_ASYNC) using async, aioapns 3.1+ is required (optional), installed aioapns overrides apns2. Setup ----- @@ -46,7 +46,7 @@ You can install the library directly from pypi using pip: .. code-block:: shell - $ pip install django-push-notifications[WP,APNS_ASYNC] + $ pip install django-push-notifications[WP,apns-async,FCM] Edit your settings.py file: diff --git a/setup.cfg b/setup.cfg index b185cd71..7e189ffb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,8 @@ APNS = WP = pywebpush>=1.3.0 +apns-async = aioapns>=3.1 + FCM = firebase-admin>=6.2 APNS_ASYNC = aioapns>=3.1 From 906e0f7ab0c157a270ad5cc1cc6e449a25561dc1 Mon Sep 17 00:00:00 2001 From: pom Date: Thu, 23 May 2024 17:21:00 +0200 Subject: [PATCH 29/31] fix NotificationResult import, add test for err_func param --- push_notifications/apns_async.py | 8 ++++-- tests/test_apns_async_push_payload.py | 35 +++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index ccf276ab..b8cb75e5 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -1,9 +1,10 @@ import asyncio -from dataclasses import asdict, dataclass import time +from dataclasses import asdict, dataclass from typing import Awaitable, Callable, Dict, Optional, Union -from aioapns import APNs, NotificationRequest, ConnectionError, NotificationResult +from aioapns import APNs, ConnectionError, NotificationRequest +from aioapns.common import NotificationResult from . import models from .conf import get_manager @@ -134,9 +135,12 @@ def send_message( self, request: NotificationRequest, ): + print("a") loop = asyncio.get_event_loop() res1 = self.client.send_notification(request) + print("b", res1) res = loop.run_until_complete(res1) + print("c", res) return res def _create_notification_request_from_args( diff --git a/tests/test_apns_async_push_payload.py b/tests/test_apns_async_push_payload.py index 4ad64284..3e7d5a78 100644 --- a/tests/test_apns_async_push_payload.py +++ b/tests/test_apns_async_push_payload.py @@ -5,6 +5,7 @@ import pytest from django.test import TestCase +from aioapns.common import NotificationResult try: from push_notifications.apns_async import TokenCredentials, apns_send_message @@ -53,11 +54,7 @@ def test_push_payload_with_thread_id(self, mock_apns): sound="chime", extra={"custom_data": 12345}, expiration=int(time.time()) + 3, - creds=TokenCredentials( - key="aaa", - key_id="bbb", - team_id="ccc", - ), + creds=TokenCredentials(key="aaa", key_id="bbb", team_id="ccc"), ) args, kwargs = mock_apns.return_value.send_notification.call_args req = args[0] @@ -159,6 +156,34 @@ def test_collapse_id(self, mock_apns): self.assertEqual(req.message["aps"]["alert"], "sample") self.assertEqual(req.collapse_key, "456789") + @mock.patch("aioapns.client.APNsCertConnectionPool", autospec=True) + @mock.patch("aioapns.client.APNsKeyConnectionPool", autospec=True) + def test_aioapns_err_func(self, mock_cert_pool, mock_key_pool): + mock_cert_pool.return_value.send_notification = mock.AsyncMock() + result = NotificationResult( + "123", "400" + ) + mock_cert_pool.return_value.send_notification.return_value = result + err_func = mock.AsyncMock() + with pytest.raises(Exception): + apns_send_message( + "123", + "sample", + creds=TokenCredentials( + key="aaa", + key_id="bbb", + team_id="ccc", + ), + topic="default", + err_func=err_func, + ) + mock_cert_pool.assert_called_once() + mock_cert_pool.return_value.send_notification.assert_called_once() + mock_cert_pool.return_value.send_notification.assert_awaited_once() + err_func.assert_called_with( + mock.ANY, result + ) + # def test_bad_priority(self): # with mock.patch("apns2.credentials.init_context"): # with mock.patch("apns2.client.APNsClient.connect"): From e4484112f2ab1a03209a52b2c8eb0155dd8a558a Mon Sep 17 00:00:00 2001 From: pom Date: Fri, 28 Jun 2024 12:40:00 +0200 Subject: [PATCH 30/31] cleanup --- push_notifications/apns_async.py | 7 ++----- tests/test_apns_async_push_payload.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/push_notifications/apns_async.py b/push_notifications/apns_async.py index b8cb75e5..a0710d85 100644 --- a/push_notifications/apns_async.py +++ b/push_notifications/apns_async.py @@ -135,12 +135,9 @@ def send_message( self, request: NotificationRequest, ): - print("a") loop = asyncio.get_event_loop() - res1 = self.client.send_notification(request) - print("b", res1) - res = loop.run_until_complete(res1) - print("c", res) + routine = self.client.send_notification(request) + res = loop.run_until_complete(routine) return res def _create_notification_request_from_args( diff --git a/tests/test_apns_async_push_payload.py b/tests/test_apns_async_push_payload.py index 3e7d5a78..ebb11416 100644 --- a/tests/test_apns_async_push_payload.py +++ b/tests/test_apns_async_push_payload.py @@ -5,9 +5,9 @@ import pytest from django.test import TestCase -from aioapns.common import NotificationResult try: + from aioapns.common import NotificationResult from push_notifications.apns_async import TokenCredentials, apns_send_message except ModuleNotFoundError: # skipping because apns2 is not supported on python 3.10 From 0177c584f29b3f90c5f17949f04e2e4620cfa84c Mon Sep 17 00:00:00 2001 From: 50-Course Date: Thu, 3 Oct 2024 19:00:37 +0100 Subject: [PATCH 31/31] Update `envlist` to support python 3.10 and 3.11 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index f981e151..1048510e 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ usedevelop = true envlist = py{37,38,39}-dj{22,32} py{38,39}-dj{40,405} + py{310,311}-dj{40,405} flake8 [gh-actions]