From 9b2bd5ec507d037f5e7539638035a6db1cf19ca2 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 14 Jul 2024 14:13:55 +0200 Subject: [PATCH] Apple Push Notifications POC --- AuthKey_DHJRAU6BCZ.p8 | 6 ++ api/serializers.py | 8 +- api/urls.py | 1 + api/views.py | 32 +++++++- requirements.txt | 2 + tournaments/admin.py | 5 ++ .../0075_alter_club_creator_devicetoken.py | 28 +++++++ .../0076_alter_devicetoken_value.py | 18 +++++ .../0077_alter_devicetoken_value.py | 18 +++++ tournaments/models/__init__.py | 1 + tournaments/models/device_token.py | 10 +++ tournaments/urls.py | 4 +- tournaments/utils/apns.py | 74 +++++++++++++++++++ tournaments/utils/cryptography.py | 2 - tournaments/views.py | 8 ++ 15 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 AuthKey_DHJRAU6BCZ.p8 create mode 100644 tournaments/migrations/0075_alter_club_creator_devicetoken.py create mode 100644 tournaments/migrations/0076_alter_devicetoken_value.py create mode 100644 tournaments/migrations/0077_alter_devicetoken_value.py create mode 100644 tournaments/models/device_token.py create mode 100644 tournaments/utils/apns.py diff --git a/AuthKey_DHJRAU6BCZ.p8 b/AuthKey_DHJRAU6BCZ.p8 new file mode 100644 index 0000000..acd45b4 --- /dev/null +++ b/AuthKey_DHJRAU6BCZ.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgtQe/9xXcsNYYOHhs +4EeJIo2qpoCZ/lLdkMnttM3jMF6gCgYIKoZIzj0DAQehRANCAAQU5IruCl0xw3xX +4WJVMZGyFINAA6nTj13nvD5P3fNzYFepgYVBy+ZBFvWrGHi75VnojiRR6v3e+z2K +0DinoPJF +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/api/serializers.py b/api/serializers.py index 535663e..ec03749 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from tournaments.models.court import Court -from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log +from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken from django.contrib.auth import password_validation from django.utils.translation import gettext_lazy as _ # email @@ -207,3 +207,9 @@ class LogSerializer(serializers.ModelSerializer): class Meta: model = Log fields = '__all__' + +class DeviceTokenSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceToken + fields = '__all__' + read_only_fields = ['user'] diff --git a/api/urls.py b/api/urls.py index b4f15d6..ff61351 100644 --- a/api/urls.py +++ b/api/urls.py @@ -20,6 +20,7 @@ router.register(r'courts', views.CourtViewSet) router.register(r'date-intervals', views.DateIntervalViewSet) router.register(r'failed-api-calls', views.FailedApiCallViewSet) router.register(r'logs', views.LogViewSet) +router.register(r'device-token', views.DeviceTokenViewSet) urlpatterns = [ path('', include(router.urls)), diff --git a/api/views.py b/api/views.py index f08d95b..bd1616e 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,5 @@ -from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, UserUpdateSerializer, FailedApiCallSerializer, LogSerializer -from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, UserUpdateSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken from rest_framework import viewsets, permissions from rest_framework.authtoken.models import Token @@ -242,3 +242,31 @@ class LogViewSet(viewsets.ModelViewSet): serializer.save(user=self.request.user) else: serializer.save() + +class DeviceTokenViewSet(viewsets.ModelViewSet): + queryset = DeviceToken.objects.all() + serializer_class = DeviceTokenSerializer + + def get_queryset(self): + if self.request.user: + return self.queryset.filter(user=self.request.user) + return [] + + # def create(self, request, *args, **kwargs): + # value = request.data.get('value') + # if DeviceToken.objects.filter(value=value).exists(): + # return Response({"detail": "This device token is already registered."}, status=208) + + # print('a') + # serializer = self.get_serializer(data=request.data) + # print('b') + # # serializer.is_valid(raise_exception=True) + # print('c') + # self.perform_create(serializer) + # print('d') + # headers = self.get_success_headers(serializer.data) + # return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + # serializer.is_valid(raise_exception=True) + serializer.save(user=self.request.user) diff --git a/requirements.txt b/requirements.txt index a4224e0..2d1556a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ dj-rest-auth==5.1.0 django-qr-code==4.0.1 pycryptodome==3.20.0 requests==2.31.0 +PyJWT==2.8.0 +hyper==0.7.0 diff --git a/tournaments/admin.py b/tournaments/admin.py index f286b46..2594f2a 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from tournaments.models import team_registration +from tournaments.models.device_token import DeviceToken from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log from django.contrib.auth.admin import UserAdmin @@ -89,6 +90,9 @@ class LogAdmin(admin.ModelAdmin): list_display = ['date', 'user', 'message'] list_filter = ['user'] +class DeviceTokenAdmin(admin.ModelAdmin): + list_display = ['user', 'value'] + admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(Club, ClubAdmin) admin.site.register(Event, EventAdmin) @@ -104,3 +108,4 @@ admin.site.register(Court, CourtAdmin) admin.site.register(DateInterval, DateIntervalAdmin) admin.site.register(FailedApiCall, FailedApiCallAdmin) admin.site.register(Log, LogAdmin) +admin.site.register(DeviceToken, DeviceTokenAdmin) diff --git a/tournaments/migrations/0075_alter_club_creator_devicetoken.py b/tournaments/migrations/0075_alter_club_creator_devicetoken.py new file mode 100644 index 0000000..e3d5a74 --- /dev/null +++ b/tournaments/migrations/0075_alter_club_creator_devicetoken.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-07-12 10:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0074_customuser_device_id'), + ] + + operations = [ + migrations.AlterField( + model_name='club', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='DeviceToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.BinaryField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/tournaments/migrations/0076_alter_devicetoken_value.py b/tournaments/migrations/0076_alter_devicetoken_value.py new file mode 100644 index 0000000..cf16620 --- /dev/null +++ b/tournaments/migrations/0076_alter_devicetoken_value.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-07-12 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0075_alter_club_creator_devicetoken'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetoken', + name='value', + field=models.CharField(max_length=240), + ), + ] diff --git a/tournaments/migrations/0077_alter_devicetoken_value.py b/tournaments/migrations/0077_alter_devicetoken_value.py new file mode 100644 index 0000000..f0bbdf3 --- /dev/null +++ b/tournaments/migrations/0077_alter_devicetoken_value.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-07-14 12:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0076_alter_devicetoken_value'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetoken', + name='value', + field=models.TextField(), + ), + ] diff --git a/tournaments/models/__init__.py b/tournaments/models/__init__.py index 3944e1d..ced18b9 100644 --- a/tournaments/models/__init__.py +++ b/tournaments/models/__init__.py @@ -15,3 +15,4 @@ from .team_score import TeamScore from .purchase import Purchase from .failed_api_call import FailedApiCall from .log import Log +from .device_token import DeviceToken diff --git a/tournaments/models/device_token.py b/tournaments/models/device_token.py new file mode 100644 index 0000000..7442d5d --- /dev/null +++ b/tournaments/models/device_token.py @@ -0,0 +1,10 @@ +from django.db import models +from . import CustomUser +import uuid + +class DeviceToken(models.Model): + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + value = models.TextField() + + def __str__(self): + return f"{self.value}" diff --git a/tournaments/urls.py b/tournaments/urls.py index 3c3f52c..0fe499f 100644 --- a/tournaments/urls.py +++ b/tournaments/urls.py @@ -30,5 +30,7 @@ urlpatterns = [ ), path('players/', views.players, name='players'), path('activate///', views.activate, name='activate'), - path('download/', views.download, name='download') + path('download/', views.download, name='download'), + path('apns/', views.test_apns, name='test-apns') + ] diff --git a/tournaments/utils/apns.py b/tournaments/utils/apns.py new file mode 100644 index 0000000..34a7174 --- /dev/null +++ b/tournaments/utils/apns.py @@ -0,0 +1,74 @@ +import http.client +import json +import jwt +import time +from hyper import HTTP20Connection + +# APPLE WARNING: Reuse a connection as long as possible. +# In most cases, you can reuse a connection for many hours to days. +# If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity. +# Reusing a connection often results in less bandwidth and CPU consumption. +# https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns + +def generate_jwt(key_path, key_id, team_id): + with open(key_path, 'r') as key_file: + key = key_file.read() + + headers = { + "alg": "ES256", + "kid": key_id + } + + payload = { + "iss": team_id, + "iat": time.time() + } + + token = jwt.encode(payload, key, algorithm="ES256", headers=headers) + return token + + +key_path = 'AuthKey_DHJRAU6BCZ.p8' +key_id = 'DHJRAU6BCZ' +team_id = 'BQ3Y44M3Q6' +bundle_id = 'app.padelclub' +# device_token = 'user_device_token' +message = 'Hello, World!' + +def send_push_notification(device_token, message): + jwt_token = generate_jwt(key_path, key_id, team_id) + + # APNs endpoint (use 'api.push.apple.com' for production) + host = 'api.sandbox.push.apple.com' + + payload = { + "aps": { + "alert": message, + "sound": "default" + } + } + + payload_json = json.dumps(payload) + + # Create the HTTP connection + connection = HTTP20Connection(host, port=443) + + # Set the headers + headers = { + "apns-topic": bundle_id, + "authorization": f"bearer {jwt_token}", + "content-type": "application/json" + } + + # Send the notification + connection.request( + "POST", + f"/3/device/{device_token}", + body=payload_json, + headers=headers + ) + + response = connection.get_response() + response_data = response.read() + + print(response.status, response.reason, response_data) diff --git a/tournaments/utils/cryptography.py b/tournaments/utils/cryptography.py index 1a69d30..172c481 100644 --- a/tournaments/utils/cryptography.py +++ b/tournaments/utils/cryptography.py @@ -1,8 +1,6 @@ from Crypto.Cipher import AES import base64 - - def decrypt_aes_gcm(encrypted_base64, key_base64): # Decode the base64 encoded data and key encrypted_data = base64.b64decode(encrypted_base64) diff --git a/tournaments/views.py b/tournaments/views.py index 0479abe..04804a0 100644 --- a/tournaments/views.py +++ b/tournaments/views.py @@ -4,6 +4,8 @@ from django.utils.encoding import force_str from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.urls import reverse +from tournaments.models.device_token import DeviceToken + # from tournaments.models import group_stage from .models import Court, DateInterval, Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall from .models import TeamSummon @@ -18,6 +20,7 @@ import json from api.tokens import account_activation_token from qr_code.qrcode.utils import QRCodeOptions +from .utils.apns import send_push_notification def index(request): @@ -281,3 +284,8 @@ def club_broadcast(request, broadcast_code): def download(request): return render(request, 'tournaments/download.html') + +def test_apns(request): + token = DeviceToken.objects.first() + send_push_notification(token.value, 'hello!') + return HttpResponse('OK!')