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

Revamp Notif Logic #324

Merged
merged 6 commits into from
Nov 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would an is_dev field not make sense here too? I don't know the Android notif system but would be surprised if they didn't have similar optionality to iOS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after talking to android didn't seem like they had



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.")

Check warning on line 45 in backend/user/notifications.py

View check run for this annotation

Codecov / codecov/patch

backend/user/notifications.py#L45

Added line #L45 was not covered by tests
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)

Check warning on line 49 in backend/user/notifications.py

View check run for this annotation

Codecov / codecov/patch

backend/user/notifications.py#L49

Added line #L49 was not covered by tests
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 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(

Check warning on line 82 in backend/user/notifications.py

View check run for this annotation

Codecov / codecov/patch

backend/user/notifications.py#L82

Added line #L82 was not covered by tests
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)

Check warning on line 97 in backend/user/notifications.py

View check run for this annotation

Codecov / codecov/patch

backend/user/notifications.py#L97

Added line #L97 was not covered by tests
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
Loading