Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Android, Extract out Notif API Logic #325

Merged
merged 12 commits into from
Nov 17, 2024
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ webdriver-manager = "*"
pre-commit = "*"
alt-profanity-check = "*"
inflection = "*"
firebase-admin = "*"

[requires]
python_version = "3.11"
1,473 changes: 914 additions & 559 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

440 changes: 155 additions & 285 deletions backend/tests/user/test_notifs.py

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions backend/user/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
from django.contrib import admin

from user.models import NotificationSetting, NotificationToken, Profile
from user.models import AndroidNotificationToken, IOSNotificationToken, NotificationService, Profile


admin.site.register(NotificationToken)
admin.site.register(NotificationSetting)
# custom IOSNotificationToken admin
class IOSNotificationTokenAdmin(admin.ModelAdmin):
list_display = ("token", "user", "is_dev")


admin.site.register(IOSNotificationToken, IOSNotificationTokenAdmin)
admin.site.register(AndroidNotificationToken)
admin.site.register(NotificationService)
admin.site.register(Profile)
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Generated by Django 5.0.2 on 2024-11-11 05:24

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("user", "0009_profile_fitness_preferences"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.RemoveField(
model_name="notificationtoken",
name="user",
),
migrations.CreateModel(
name="AndroidNotificationToken",
fields=[
("token", models.CharField(max_length=255, primary_key=True, serialize=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="IOSNotificationToken",
fields=[
("token", models.CharField(max_length=255, primary_key=True, serialize=False)),
("is_dev", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="NotificationService",
fields=[
("name", models.CharField(max_length=255, primary_key=True, serialize=False)),
("enabled_users", models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
],
),
migrations.DeleteModel(
name="NotificationSetting",
),
migrations.DeleteModel(
name="NotificationToken",
),
]
63 changes: 17 additions & 46 deletions backend/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,24 @@


class NotificationToken(models.Model):
KIND_IOS = "IOS"
KIND_ANDROID = "ANDROID"
KIND_OPTIONS = ((KIND_IOS, "iOS"), (KIND_ANDROID, "Android"))
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=255, primary_key=True)

class Meta:
abstract = True


class IOSNotificationToken(NotificationToken):
is_dev = models.BooleanField(default=False)

user = models.OneToOneField(User, on_delete=models.CASCADE)
kind = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_IOS)
token = models.CharField(max_length=255)


class NotificationSetting(models.Model):
SERVICE_CFA = "CFA"
SERVICE_PENN_CLUBS = "PENN_CLUBS"
SERVICE_PENN_BASICS = "PENN_BASICS"
SERVICE_OHQ = "OHQ"
SERVICE_PENN_COURSE_ALERT = "PENN_COURSE_ALERT"
SERVICE_PENN_COURSE_PLAN = "PENN_COURSE_PLAN"
SERVICE_PENN_COURSE_REVIEW = "PENN_COURSE_REVIEW"
SERVICE_PENN_MOBILE = "PENN_MOBILE"
SERVICE_GSR_BOOKING = "GSR_BOOKING"
SERVICE_DINING = "DINING"
SERVICE_UNIVERSITY = "UNIVERSITY"
SERVICE_LAUNDRY = "LAUNDRY"
SERVICE_OPTIONS = (
(SERVICE_CFA, "CFA"),
(SERVICE_PENN_CLUBS, "Penn Clubs"),
(SERVICE_PENN_BASICS, "Penn Basics"),
(SERVICE_OHQ, "OHQ"),
(SERVICE_PENN_COURSE_ALERT, "Penn Course Alert"),
(SERVICE_PENN_COURSE_PLAN, "Penn Course Plan"),
(SERVICE_PENN_COURSE_REVIEW, "Penn Course Review"),
(SERVICE_PENN_MOBILE, "Penn Mobile"),
(SERVICE_GSR_BOOKING, "GSR Booking"),
(SERVICE_DINING, "Dining"),
(SERVICE_UNIVERSITY, "University"),
(SERVICE_LAUNDRY, "Laundry"),
)

token = models.ForeignKey(NotificationToken, on_delete=models.CASCADE)
service = models.CharField(max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE)
enabled = models.BooleanField(default=True)

class AndroidNotificationToken(NotificationToken):
pass


class NotificationService(models.Model):
name = models.CharField(max_length=255, primary_key=True)
enabled_users = models.ManyToManyField(User, blank=True)


class Profile(models.Model):
Expand All @@ -70,10 +48,3 @@ def create_or_update_user_profile(sender, instance, created, **kwargs):
object exists for that User, it will create one
"""
Profile.objects.get_or_create(user=instance)

# notifications
token, _ = NotificationToken.objects.get_or_create(user=instance)
for service, _ in NotificationSetting.SERVICE_OPTIONS:
setting = NotificationSetting.objects.filter(token=token, service=service).first()
if not setting:
NotificationSetting.objects.create(token=token, service=service, enabled=False)
202 changes: 117 additions & 85 deletions backend/user/notifications.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import collections
import os
import sys
from abc import ABC, abstractmethod

import firebase_admin
from firebase_admin import credentials, messaging


# Monkey Patch for apn2 errors, referenced from:
Expand All @@ -18,106 +22,134 @@
collections.MutableSet = abc.MutableSet
collections.MutableMapping = abc.MutableMapping

from apns2.client import APNsClient
from apns2.credentials import TokenCredentials
from apns2.client import APNsClient, Notification
from apns2.payload import Payload
from celery import shared_task

from user.models import NotificationToken

class NotificationWrapper(ABC):
def send_notification(self, tokens, title, body):
self.send_payload(tokens, self.create_payload(title, body))

def send_shadow_notification(self, tokens, body):
self.send_payload(tokens, self.create_shadow_payload(body))

def send_payload(self, tokens, payload):
if len(tokens) == 0:
raise ValueError("No tokens provided")
elif len(tokens) > 1:
self.send_many_notifications(tokens, payload)
else:
self.send_one_notification(tokens[0], payload)

@abstractmethod
def create_payload(self, title, body):
raise NotImplementedError # pragma: no cover

@abstractmethod
def create_shadow_payload(self, body):
raise NotImplementedError

@abstractmethod
def send_many_notifications(self, tokens, payload):
raise NotImplementedError # pragma: no cover

@abstractmethod
def send_one_notification(self, token, payload):
raise NotImplementedError


class AndroidNotificationWrapper(NotificationWrapper):
def __init__(self):
try:
server_key = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"penn-mobile-android-firebase-adminsdk-u9rki-c83fb20713.json",
)
cred = credentials.Certificate(server_key)
firebase_admin.initialize_app(cred)
except Exception as e:
print(f"Notifications Error: Failed to initialize Firebase client: {e}")

def create_payload(self, title, body):
return {"notification": messaging.Notification(title=title, body=body)}

def create_shadow_payload(self, body):
return {"data": body}

def send_many_notifications(self, tokens, payload):
message = messaging.MulticastMessage(tokens=tokens, **payload)
messaging.send_each_for_multicast(message)
# TODO: log response errors

def send_one_notification(self, token, payload):
message = messaging.Message(token=token, **payload)
messaging.send(message)


class IOSNotificationWrapper(NotificationWrapper):
@staticmethod
def get_client(is_dev):
auth_key_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
f"apns-{'dev' if is_dev else 'prod'}.pem",
)
return APNsClient(credentials=auth_key_path, use_sandbox=is_dev)

def __init__(self, is_dev=False):
try:
self.client = self.get_client(is_dev)
self.topic = "org.pennlabs.PennMobile" + (".dev" if is_dev else "")
except Exception as e:
print(f"Notifications Error: Failed to initialize APNs client: {e}")

def create_payload(self, title, body):
# TODO: we might want to add category here, but there is no use on iOS side for now
return Payload(
alert={"title": title, "body": body}, sound="default", badge=0, mutable_content=True
)

# taken from the apns2 method for batch notifications
Notification = collections.namedtuple("Notification", ["token", "payload"])
def create_shadow_payload(self, body):
return Payload(content_available=True, custom=body, mutable_content=True)

def send_many_notifications(self, tokens, payload):
notifications = [Notification(token, payload) for token in tokens]
self.client.send_notification_batch(notifications=notifications, topic=self.topic)

def send_push_notifications(users, service, title, body, delay=0, is_dev=False, is_shadow=False):
"""
Sends push notifications.

:param users: list of usernames to send notifications to or 'None' if to all
:param service: service to send notifications for or 'None' if ignoring settings
:param title: title of notification
:param body: body of notification
:param delay: delay in seconds before sending notification
:param isShadow: whether to send a shadow notification
:return: tuple of (list of success usernames, list of failed usernames)
"""
def send_one_notification(self, token, payload):
self.client.send_notification(token, payload, self.topic)

# collect available usernames & their respective device tokens
token_objects = get_tokens(users, service)
if not token_objects:
return [], users
success_users, tokens = zip(*token_objects)

# send notifications
if delay:
send_delayed_notifications(tokens, title, body, service, is_dev, is_shadow, delay)
else:
send_immediate_notifications(tokens, title, body, service, is_dev, is_shadow)

if not users: # if to all users, can't be any failed pennkeys
return success_users, []
failed_users = list(set(users) - set(success_users))
return success_users, failed_users


def get_tokens(users=None, service=None):
"""Returns list of token objects (with username & token value) for specified users"""

token_objs = NotificationToken.objects.select_related("user").filter(
kind=NotificationToken.KIND_IOS # NOTE: until Android implementation
)
if users:
token_objs = token_objs.filter(user__username__in=users)
if service:
token_objs = token_objs.filter(
notificationsetting__service=service, notificationsetting__enabled=True
)
return token_objs.exclude(token="").values_list("user__username", "token")

IOSNotificationSender = IOSNotificationWrapper()
AndroidNotificationSender = AndroidNotificationWrapper()
IOSNotificationDevSender = IOSNotificationWrapper(is_dev=True)

@shared_task(name="notifications.send_immediate_notifications")
def send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow):
client = get_client(is_dev)
if is_shadow:
payload = Payload(
content_available=True, custom=body, mutable_content=True, category=category
)
else:
alert = {"title": title, "body": body}
payload = Payload(
alert=alert, sound="default", badge=0, mutable_content=True, category=category
)
topic = "org.pennlabs.PennMobile" + (".dev" if is_dev else "")

if len(tokens) > 1:
notifications = [Notification(token, payload) for token in tokens]
client.send_notification_batch(notifications=notifications, topic=topic)
else:
client.send_notification(tokens[0], payload, topic)
@shared_task(name="notifications.ios_send_notification")
def ios_send_notification(tokens, title, body):
IOSNotificationSender.send_notification(tokens, title, body)


@shared_task(name="notifications.ios_send_shadow_notification")
def ios_send_shadow_notification(tokens, body):
IOSNotificationSender.send_shadow_notification(tokens, body)


@shared_task(name="notifications.android_send_notification")
def android_send_notification(tokens, title, body):
AndroidNotificationSender.send_notification(tokens, title, body)

def send_delayed_notifications(tokens, title, body, category, is_dev, is_shadow, delay):
send_immediate_notifications.apply_async(
(tokens, title, body, category, is_dev, is_shadow), countdown=delay
)

@shared_task(name="notifications.android_send_shadow_notification")
def android_send_shadow_notification(tokens, body):
AndroidNotificationSender.send_shadow_notification(tokens, body)

def get_auth_key_path():
return os.environ.get(
"IOS_KEY_PATH", # for dev purposes
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ios_key.p8"),
)

@shared_task(name="notifications.ios_send_dev_notification")
def ios_send_dev_notification(tokens, title, body):
IOSNotificationDevSender.send_notification(tokens, title, body)

def get_client(is_dev):
"""Creates and returns APNsClient based on iOS credentials"""

auth_key_path = get_auth_key_path()
auth_key_id = "2VX9TC37TB"
team_id = "VU59R57FGM"
token_credentials = TokenCredentials(
auth_key_path=auth_key_path, auth_key_id=auth_key_id, team_id=team_id
)
client = APNsClient(credentials=token_credentials, use_sandbox=is_dev)
return client
@shared_task(name="notifications.ios_send_dev_shadow_notification")
def ios_send_dev_shadow_notification(tokens, body):
IOSNotificationDevSender.send_shadow_notification(tokens, body)
Loading
Loading