Skip to content

Commit

Permalink
Revamp Notif Logic (#324)
Browse files Browse the repository at this point in the history
* Rewrite notif logic according to new specs

* fix tests and comment out a lot
  • Loading branch information
vcai122 authored Nov 17, 2024
1 parent 44965e9 commit 8315c52
Show file tree
Hide file tree
Showing 9 changed files with 377 additions and 485 deletions.
436 changes: 153 additions & 283 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)
65 changes: 20 additions & 45 deletions backend/user/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,61 +19,36 @@
collections.MutableMapping = abc.MutableMapping

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

from user.models import NotificationToken


# taken from the apns2 method for batch notifications
Notification = collections.namedtuple("Notification", ["token", "payload"])


def send_push_notifications(users, service, title, body, delay=0, is_dev=False, is_shadow=False):
def send_push_notifications(tokens, category, 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 tokens: nonempty list of tokens to send notifications to
:param category: category to send notifications for
: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)
"""

# 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 tokens == []:
raise ValueError("No tokens to send notifications to.")
params = (tokens, title, body, category, is_dev, is_shadow)

if delay:
send_delayed_notifications(tokens, title, body, service, is_dev, is_shadow, delay)
send_delayed_notifications(*params, delay=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")
send_immediate_notifications(*params)


@shared_task(name="notifications.send_immediate_notifications")
Expand Down Expand Up @@ -103,21 +78,21 @@ def send_delayed_notifications(tokens, title, body, category, is_dev, is_shadow,
)


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"),
def get_auth_key_path(is_dev):
return os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
f"apns-{'dev' if is_dev else 'prod'}.pem",
)


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)
# 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=get_auth_key_path(is_dev), use_sandbox=is_dev)
return client
35 changes: 1 addition & 34 deletions backend/user/serializers.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,7 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers

from user.models import NotificationSetting, NotificationToken, Profile


class NotificationTokenSerializer(serializers.ModelSerializer):
class Meta:
model = NotificationToken
fields = ("id", "kind", "token")

def create(self, validated_data):
validated_data["user"] = self.context["request"].user
token_obj = NotificationToken.objects.filter(user=validated_data["user"]).first()
if token_obj:
raise serializers.ValidationError(detail={"detail": "Token already created."})
return super().create(validated_data)


class NotificationSettingSerializer(serializers.ModelSerializer):
class Meta:
model = NotificationSetting
fields = ("id", "service", "enabled")

def create(self, validated_data):
validated_data["token"] = NotificationToken.objects.get(user=self.context["request"].user)
setting = NotificationSetting.objects.filter(
token=validated_data["token"], service=validated_data["service"]
).first()
if setting:
raise serializers.ValidationError(detail={"detail": "Setting already created."})
return super().create(validated_data)

def update(self, instance, validated_data):
if instance.service != validated_data["service"]:
raise serializers.ValidationError(detail={"detail": "Cannot change setting service."})
return super().update(instance, validated_data)
from user.models import Profile


class ProfileSerializer(serializers.ModelSerializer):
Expand Down
19 changes: 15 additions & 4 deletions backend/user/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,32 @@
from rest_framework import routers

from user.views import (
AndroidNotificationTokenView,
ClearCookiesView,
IOSNotificationTokenView,
NotificationAlertView,
NotificationSettingView,
NotificationTokenView,
NotificationServiceSettingView,
NotificationServiceView,
UserView,
)


app_name = "user"

router = routers.DefaultRouter()
router.register(r"notifications/tokens", NotificationTokenView, basename="notiftokens")
router.register(r"notifications/settings", NotificationSettingView, basename="notifsettings")
# router.register(r"notifications/settings", NotificationSettingView, basename="notifsettings")

additional_urls = [
path("notifications/tokens/ios/<token>/", IOSNotificationTokenView.as_view(), name="ios-token"),
path(
"notifications/tokens/android/<token>/",
AndroidNotificationTokenView.as_view(),
name="android-token",
),
path(
"notifications/settings/", NotificationServiceSettingView.as_view(), name="notif-settings"
),
path("notifications/services/", NotificationServiceView.as_view(), name="notif-services"),
path("me/", UserView.as_view(), name="user"),
path("notifications/alerts/", NotificationAlertView.as_view(), name="alert"),
path("clear-cookies/", ClearCookiesView.as_view(), name="clear-cookies"),
Expand Down
Loading

0 comments on commit 8315c52

Please sign in to comment.