From 2d60616e17d57e17dba3ee910475d67bc518acb6 Mon Sep 17 00:00:00 2001 From: vcai122 Date: Sun, 10 Nov 2024 23:01:13 -0500 Subject: [PATCH 1/5] Remove Me Groups --- backend/gsr_booking/serializers.py | 31 ----------- backend/gsr_booking/urls.py | 4 +- backend/gsr_booking/views.py | 53 ++++--------------- backend/tests/gsr_booking/test_gsr_booking.py | 33 ++++++------ backend/tests/user/test_notifs.py | 3 +- backend/user/models.py | 2 - 6 files changed, 27 insertions(+), 99 deletions(-) diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index 1c94873e..76bf5cca 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -20,21 +20,13 @@ def get_is_wharton(self, obj): return obj["lid"] == 1 -class MiniUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["username", "first_name", "last_name"] - - class GroupMembershipSerializer(serializers.ModelSerializer): - user = MiniUserSerializer(read_only=True) group = serializers.SlugRelatedField(slug_field="name", queryset=Group.objects.all()) color = serializers.SlugRelatedField(slug_field="color", read_only=True, source="group") class Meta: model = GroupMembership fields = [ - "user", "group", "type", "pennkey_allow", @@ -73,29 +65,6 @@ def to_internal_value(self, data): return None # TODO: If you want to update based on BookingField, implement this. -class UserSerializer(serializers.ModelSerializer): - booking_groups = serializers.SerializerMethodField() - - def get_booking_groups(self, obj): - result = [] - for membership in GroupMembership.objects.filter(accepted=True, user=obj): - result.append( - { - "name": membership.group.name, - "id": membership.group.id, - "color": membership.group.color, - "pennkey_allow": membership.pennkey_allow, - "notifications": membership.notifications, - } - ) - - return result - - class Meta: - model = User - fields = ["username", "booking_groups"] - - class GSRSerializer(serializers.ModelSerializer): class Meta: model = GSR diff --git a/backend/gsr_booking/urls.py b/backend/gsr_booking/urls.py index 7f6e2b01..a54a1e12 100644 --- a/backend/gsr_booking/urls.py +++ b/backend/gsr_booking/urls.py @@ -10,16 +10,16 @@ GroupMembershipViewSet, GroupViewSet, Locations, + MyMembershipViewSet, RecentGSRs, ReservationsView, - UserViewSet, ) from utils.cache import Cache router = routers.DefaultRouter() -router.register(r"users", UserViewSet) +router.register(r"mymemberships", MyMembershipViewSet, "mymemberships") router.register(r"membership", GroupMembershipViewSet) router.register(r"groups", GroupViewSet) diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index 9bf6aa3f..e4b9eac5 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -11,66 +11,31 @@ from gsr_booking.api_wrapper import APIError, GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -from gsr_booking.serializers import ( - GroupMembershipSerializer, - GroupSerializer, - GSRSerializer, - UserSerializer, -) +from gsr_booking.serializers import GroupMembershipSerializer, GroupSerializer, GSRSerializer from pennmobile.analytics import Metric, record_analytics User = get_user_model() -class UserViewSet(viewsets.ReadOnlyModelViewSet): - """ - Can specify `me` instead of the `username` to retrieve details on the current user. - """ - - queryset = User.objects.all().prefetch_related( - Prefetch("booking_groups", Group.objects.filter(memberships__accepted=True)) - ) +class MyMembershipViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - lookup_field = "username" - filter_backends = [DjangoFilterBackend] - filterset_fields = ["username", "first_name", "last_name"] - - def get_object(self): - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - param = self.kwargs[lookup_url_kwarg] - if param == "me": - return self.request.user - else: - return super().get_object() + serializer_class = GroupMembershipSerializer def get_queryset(self): - if not self.request.user.is_authenticated: - return User.objects.none() + return GroupMembership.objects.filter(user=self.request.user, accepted=True) - queryset = User.objects.all() - queryset = queryset.prefetch_related( - Prefetch( - "memberships", - GroupMembership.objects.filter( - group__in=self.request.user.booking_groups.all(), accepted=True - ), - ) - ) - return queryset - - @action(detail=True, methods=["get"]) - def invites(self, request, username=None): + @action(detail=False, methods=["get"]) + def invites(self, request): """ Retrieve all invites for a given user. """ - - user = get_object_or_404(User, username=username) return Response( GroupMembershipSerializer( GroupMembership.objects.filter( - user=user, accepted=False, group__in=self.request.user.booking_groups.all() + user=request.user, + accepted=False, + group__in=self.request.user.booking_groups.all(), ), many=True, ).data diff --git a/backend/tests/gsr_booking/test_gsr_booking.py b/backend/tests/gsr_booking/test_gsr_booking.py index 1559dbbd..7965e35f 100644 --- a/backend/tests/gsr_booking/test_gsr_booking.py +++ b/backend/tests/gsr_booking/test_gsr_booking.py @@ -8,7 +8,7 @@ User = get_user_model() -class UserViewTestCase(TestCase): +class MyMembershipViewTestCase(TestCase): def setUp(self): self.user1 = User.objects.create_user( username="user1", password="password", first_name="user", last_name="one" @@ -17,28 +17,25 @@ def setUp(self): username="user2", password="password", first_name="user", last_name="two" ) - self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") - self.group.members.add(self.user1) - memship = self.group.memberships.all()[0] - memship.accepted = True - memship.save() + Group.objects.create( + owner=self.user1, name="g1", color="blue" + ) # creating group also adds user + group2 = Group.objects.create(owner=self.user2, name="g2", color="blue") + GroupMembership.objects.create(user=self.user1, group=group2, accepted=True) + group3 = Group.objects.create(owner=self.user2, name="g3", color="blue") + GroupMembership.objects.create(user=self.user1, group=group3) self.client = APIClient() self.client.login(username="user1", password="password") - def test_user_list(self): - response = self.client.get("/gsr/users/") + def test_user_memberships(self): + response = self.client.get("/gsr/mymemberships/") self.assertTrue(200, response.status_code) self.assertEqual(2, len(response.data)) - def test_user_detail_in_group(self): - response = self.client.get("/gsr/users/user1/") - self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) - - def test_me_user_detail_in_group(self): - response = self.client.get("/gsr/users/me/") + def test_user_invites(self): + response = self.client.get("/gsr/mymemberships/invites/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) + self.assertEqual(1, len(response.data)) class MembershipViewTestCase(TestCase): @@ -159,7 +156,7 @@ def setUp(self): def test_get_groups(self): response = self.client.get("/gsr/groups/") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(1, len(response.data)) def test_get_groups_includes_invites(self): GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) @@ -173,7 +170,7 @@ def test_get_group_not_involved_fails(self): def test_make_group(self): response = self.client.post("/gsr/groups/", {"name": "gx", "color": "blue"}) self.assertEqual(201, response.status_code, response.data) - self.assertEqual(5, Group.objects.count()) + self.assertEqual(3, Group.objects.count()) self.assertEqual("user1", Group.objects.get(name="gx").owner.username) def test_only_accepted_memberships(self): diff --git a/backend/tests/user/test_notifs.py b/backend/tests/user/test_notifs.py index 512b271a..cbeb8075 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -9,7 +9,7 @@ from identity.identity import attest, container, get_platform_jwks from rest_framework.test import APIClient -from gsr_booking.models import GSR, Group, GSRBooking, Reservation +from gsr_booking.models import GSR, GSRBooking, Reservation from user.models import NotificationSetting, NotificationToken @@ -350,7 +350,6 @@ def setUp(self): start=g.start, end=g.end, creator=self.test_user, - group=Group.objects.get(owner=self.test_user), ) g.reservation = r diff --git a/backend/user/models.py b/backend/user/models.py index deb3714b..dc71b5c3 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -3,7 +3,6 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from gsr_booking.models import Group from laundry.models import LaundryRoom from penndata.models import FitnessRoom @@ -71,7 +70,6 @@ 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) - Group.objects.get_or_create(owner=instance, name="Me", color="#14f7d1") # notifications token, _ = NotificationToken.objects.get_or_create(user=instance) From dedf089756247831e9980d55a243e0454372506d Mon Sep 17 00:00:00 2001 From: vcai122 Date: Mon, 11 Nov 2024 10:48:32 -0500 Subject: [PATCH 2/5] Rewrite notif logic according to new specs, testing still needed --- backend/tests/user/test_notifs.py | 14 ++- backend/user/admin.py | 10 +- ..._remove_notificationtoken_user_and_more.py | 64 ++++++++++ backend/user/models.py | 85 +++++++------ backend/user/notifications.py | 38 ++---- backend/user/serializers.py | 54 +++----- backend/user/urls.py | 13 +- backend/user/views.py | 115 +++++++++--------- 8 files changed, 216 insertions(+), 177 deletions(-) create mode 100644 backend/user/migrations/0010_remove_notificationtoken_user_and_more.py diff --git a/backend/tests/user/test_notifs.py b/backend/tests/user/test_notifs.py index cbeb8075..753b4dde 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from gsr_booking.models import GSR, GSRBooking, Reservation -from user.models import NotificationSetting, NotificationToken +from user.models import NotificationToken, NotificationService User = get_user_model() @@ -100,6 +100,12 @@ class TestNotificationSetting(TestCase): """Tests for CRUD Notification Settings""" def setUp(self): + NotificationService.objects.bulk_create( + [ + NotificationService(name="PENN_MOBILE"), + NotificationService(name="OHQ"), + ] + ) self.client = APIClient() self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") self.client.force_authenticate(user=self.test_user) @@ -107,11 +113,9 @@ def setUp(self): def test_get_settings(self): # test that settings visible via GET - response = self.client.get("/user/notifications/settings/") + response = self.client.get("/user/notifications/services/") res_json = json.loads(response.content) - self.assertEqual(len(NotificationSetting.SERVICE_OPTIONS), len(res_json)) - for setting in res_json: - self.assertFalse(setting["enabled"]) + self.assertEqual(2, len(res_json)) def test_invalid_settings_update(self): NotificationToken.objects.all().delete() diff --git a/backend/user/admin.py b/backend/user/admin.py index 75fe9c36..ba7ca5e2 100644 --- a/backend/user/admin.py +++ b/backend/user/admin.py @@ -1,8 +1,12 @@ from django.contrib import admin -from user.models import NotificationSetting, NotificationToken, Profile +from user.models import IOSNotificationToken, AndroidNotificationToken, NotificationService, Profile +# custom IOSNotificationToken admin +class IOSNotificationTokenAdmin(admin.ModelAdmin): + list_display = ('token', 'user', 'is_dev') -admin.site.register(NotificationToken) -admin.site.register(NotificationSetting) +admin.site.register(IOSNotificationToken, IOSNotificationTokenAdmin) +admin.site.register(AndroidNotificationToken) +admin.site.register(NotificationService) admin.site.register(Profile) diff --git a/backend/user/migrations/0010_remove_notificationtoken_user_and_more.py b/backend/user/migrations/0010_remove_notificationtoken_user_and_more.py new file mode 100644 index 00000000..0d9f3b30 --- /dev/null +++ b/backend/user/migrations/0010_remove_notificationtoken_user_and_more.py @@ -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", + ), + ] diff --git a/backend/user/models.py b/backend/user/models.py index dc71b5c3..08789f7c 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -11,46 +11,52 @@ 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 - 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 IOSNotificationToken(NotificationToken): + is_dev = models.BooleanField(default=False) + +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 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"), - ) +# 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) + +# service = models.CharField(max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE) +# enabled = models.BooleanField(default=True) class Profile(models.Model): @@ -70,10 +76,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) diff --git a/backend/user/notifications.py b/backend/user/notifications.py index 3cc73547..aafe9b44 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -23,19 +23,18 @@ 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 @@ -43,37 +42,16 @@ def send_push_notifications(users, service, title, body, delay=0, is_dev=False, :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.") + if delay: - send_delayed_notifications(tokens, title, body, service, is_dev, is_shadow, delay) + send_delayed_notifications(tokens, title, body, category, 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 + send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow) -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") @shared_task(name="notifications.send_immediate_notifications") diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 94a17def..440537f8 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -1,40 +1,26 @@ 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 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) class ProfileSerializer(serializers.ModelSerializer): diff --git a/backend/user/urls.py b/backend/user/urls.py index 5e06de40..6667d63e 100644 --- a/backend/user/urls.py +++ b/backend/user/urls.py @@ -4,8 +4,10 @@ from user.views import ( ClearCookiesView, NotificationAlertView, - NotificationSettingView, - NotificationTokenView, + IOSNotificationTokenView, + AndroidNotificationTokenView, + NotificationServiceSettingView, + NotificationServiceView, UserView, ) @@ -13,10 +15,13 @@ 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//", IOSNotificationTokenView.as_view(), name="ios-token"), + path("notifications/tokens/android//", 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"), diff --git a/backend/user/views.py b/backend/user/views.py index 48ea7e46..f4843ce3 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -8,14 +8,14 @@ from rest_framework.response import Response from rest_framework.views import APIView -from user.models import NotificationSetting, NotificationToken +from user.models import IOSNotificationToken, AndroidNotificationToken, NotificationService from user.notifications import send_push_notifications from user.serializers import ( - NotificationSettingSerializer, - NotificationTokenSerializer, UserSerializer, ) +from abc import ABC + User = get_user_model() @@ -40,64 +40,53 @@ def get_object(self): return self.request.user -class NotificationTokenView(viewsets.ModelViewSet): - """ - get: - Return notification tokens of user. - """ - +class NotificationTokenView(APIView, ABC): permission_classes = [IsAuthenticated] - serializer_class = NotificationTokenSerializer + queryset = None - def get_queryset(self): - return NotificationToken.objects.filter(user=self.request.user) + def get_defaults(self): + raise NotImplementedError # pragma: no cover + def post(self, request, token): + _, created = self.queryset.update_or_create(token=token, defaults=self.get_defaults()) + if created: + return Response({"detail": "Token created."}, status=201) + return Response({"detail": "Token updated."}, status=200) -class NotificationSettingView(viewsets.ModelViewSet): - """ - get: - Return notification settings of user. + def delete(self, request, token): + self.queryset.filter(token=token).delete() + return Response({"detail": "Token deleted."}, status=200) - post: - Creates/updates new notification setting of user for a specific service. +class IOSNotificationTokenView(NotificationTokenView): + queryset = IOSNotificationToken.objects.all() + def get_defaults(self): + is_dev = self.request.data.get("is_dev", "false").lower() == "true" + return {"user": self.request.user, "is_dev": is_dev} - check: - Checks if user wants notification for specified serice. - """ +class AndroidNotificationTokenView(NotificationTokenView): + queryset = AndroidNotificationToken.objects.all() + def get_defaults(self): + return {"user": self.request.user} - permission_classes = [B2BPermission("urn:pennlabs:*") | IsAuthenticated] - serializer_class = NotificationSettingSerializer - - def is_authorized(self, request): - return request.user and request.user.is_authenticated - - def get_queryset(self): - if self.is_authorized(self.request): - return NotificationSetting.objects.filter(token__user=self.request.user) - return NotificationSetting.objects.none() - - @action(detail=True, methods=["get"]) - def check(self, request, pk=None): - """ - Returns whether the user wants notification for specified service. - :param pk: service name - """ - - if pk not in dict(NotificationSetting.SERVICE_OPTIONS): - return Response({"detail": "Invalid Parameters."}, status=400) - - pennkey = request.GET.get("pennkey") - user = ( - request.user - if self.is_authorized(request) - else get_object_or_404(User, username=pennkey) +class NotificationServiceSettingView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + services = NotificationService.objects.all().prefetch_related("enabled_users") + return Response( + { + service.name: service.enabled_users.filter(id=user.id).exists() + for service in services + } ) - token = NotificationToken.objects.filter(user=user).first() - if not token: - return Response({"service": pk, "enabled": False}) - setting, _ = NotificationSetting.objects.get_or_create(token=token, service=pk) - return Response(NotificationSettingSerializer(setting).data) +class NotificationServiceView(APIView): + permission_classes = [IsAuthenticated] + + # TODO: this is becoming a common pattern, consider abstracting + def get(self, request): + return Response(NotificationService.objects.all().values_list("name", flat=True)) class NotificationAlertView(APIView): @@ -109,27 +98,37 @@ class NotificationAlertView(APIView): permission_classes = [B2BPermission("urn:pennlabs:*") | IsAuthenticated] def post(self, request): - users = ( + usernames = ( [self.request.user.username] if request.user and request.user.is_authenticated else request.data.get("users", list()) ) + service = request.data.get("service") title = request.data.get("title") body = request.data.get("body") delay = max(request.data.get("delay", 0), 0) - is_dev = request.data.get("is_dev", False) + is_dev = request.data.get("is_dev", "false").lower() == "true" if None in [service, title, body]: return Response({"detail": "Missing required parameters."}, status=400) - if service not in dict(NotificationSetting.SERVICE_OPTIONS): + + if not (service_obj := NotificationService.objects.filter(name=service).first()): return Response({"detail": "Invalid service."}, status=400) + + users_with_service = service_obj.enabled_users.filter(username__in=usernames) + + tokens = list(IOSNotificationToken.objects.filter(user__in=users_with_service, is_dev=is_dev).values_list( + "token", flat=True + )) - success_users, failed_users = send_push_notifications( - users, service, title, body, delay, is_dev + send_push_notifications( + tokens, service, title, body, delay, is_dev ) - return Response({"success_users": success_users, "failed_users": failed_users}) + users_with_service_usernames = users_with_service.values_list("username", flat=True) + users_not_reached_usernames = list(set(usernames) - set(users_with_service_usernames)) + return Response({"success_users": users_with_service_usernames, "failed_users": users_not_reached_usernames}) class ClearCookiesView(APIView): From 98296266dc0547c08f007b2cd8c1cf73db26dba0 Mon Sep 17 00:00:00 2001 From: vcai122 Date: Mon, 11 Nov 2024 22:27:33 -0500 Subject: [PATCH 3/5] what is happening im so bad at git --- backend/portal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/portal/models.py b/backend/portal/models.py index b15aeee1..7f58e493 100644 --- a/backend/portal/models.py +++ b/backend/portal/models.py @@ -63,7 +63,7 @@ def _on_create(self): self._get_email_subject(), get_backend_manager_emails(), ( - f"A new {self.__class__._meta.model_name} for {self.club_code}" + f"A new {self.__class__._meta.model_name} for {self.club_code} " f"has been created by {self.creator}." ), ) From de298b6026c89fb61224365063a668166b1db4ed Mon Sep 17 00:00:00 2001 From: vcai122 Date: Sun, 17 Nov 2024 00:19:25 -0500 Subject: [PATCH 4/5] fix tests and stuff --- backend/tests/user/test_notifs.py | 426 ++++++++++-------------------- backend/user/admin.py | 6 +- backend/user/models.py | 36 +-- backend/user/notifications.py | 26 +- backend/user/serializers.py | 1 + backend/user/urls.py | 14 +- backend/user/views.py | 73 +++-- k8s/main.ts | 14 +- 8 files changed, 234 insertions(+), 362 deletions(-) diff --git a/backend/tests/user/test_notifs.py b/backend/tests/user/test_notifs.py index 753b4dde..6fa67b79 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -1,16 +1,12 @@ -import datetime import json from unittest import mock from django.contrib.auth import get_user_model -from django.core.management import call_command -from django.test import TestCase -from django.utils import timezone +from django.test import TestCase, TransactionTestCase from identity.identity import attest, container, get_platform_jwks from rest_framework.test import APIClient -from gsr_booking.models import GSR, GSRBooking, Reservation -from user.models import NotificationToken, NotificationService +from user.models import IOSNotificationToken, NotificationService User = get_user_model() @@ -42,61 +38,48 @@ def mock_client(is_dev): return MockAPNsClient() -class TestNotificationToken(TestCase): - """Tests for CRUD Notification Tokens""" +class TestIOSNotificationToken(TestCase): + """Tests for associating and deleting IOS Notification Tokens""" def setUp(self): self.client = APIClient() self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") self.client.force_authenticate(user=self.test_user) - - def test_post_save(self): - # asserts that post save hook in creating tokens works correctly - self.assertEqual(1, NotificationToken.objects.all().count()) - self.assertEqual(self.test_user, NotificationToken.objects.all().first().user) - - def test_create_update_token(self): - NotificationToken.objects.all().delete() - - # test that creating token returns correct response - payload = {"kind": "IOS", "token": "test123"} - response = self.client.post("/user/notifications/tokens/", payload) - res_json = json.loads(response.content) - self.assertEqual("IOS", res_json["kind"]) - self.assertEqual("test123", res_json["token"]) - self.assertEqual(3, len(res_json)) - self.assertEqual(1, NotificationToken.objects.all().count()) - - # update token - new_payload = {"kind": "IOS", "token": "newtoken"} - response = self.client.patch(f"/user/notifications/tokens/{res_json['id']}/", new_payload) - res_json = json.loads(response.content) - self.assertEqual("newtoken", res_json["token"]) - self.assertEqual(1, NotificationToken.objects.all().count()) - - def test_create_token_again_fail(self): - # test that creating token returns correct response - payload = {"kind": "IOS", "token": "test123"} - response = self.client.post("/user/notifications/tokens/", payload) - self.assertEqual(response.status_code, 400) - - def test_get_token(self): - NotificationToken.objects.all().delete() - - # create token - payload = {"kind": "IOS", "token": "test123"} - response = self.client.post("/user/notifications/tokens/", payload) - - response = self.client.get("/user/notifications/tokens/") - res_json = json.loads(response.content) - self.assertEqual("IOS", res_json[0]["kind"]) - self.assertEqual("test123", res_json[0]["token"]) - self.assertEqual(1, len(res_json)) - self.assertEqual(3, len(res_json[0])) - self.assertEqual(1, NotificationToken.objects.all().count()) - - -class TestNotificationSetting(TestCase): + self.token = "1234" + + def test_create_token(self): + response = self.client.post(f"/user/notifications/tokens/ios/{self.token}/") + self.assertEqual(201, response.status_code) + self.assertEqual(1, IOSNotificationToken.objects.all().count()) + the_token = IOSNotificationToken.objects.first() + self.assertEqual(self.token, the_token.token) + self.assertEqual(self.test_user, the_token.user) + self.assertEqual(False, the_token.is_dev) + + def test_update_token(self): + # test that posting to same token updates the token + user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + IOSNotificationToken.objects.create(user=user2, token=self.token, is_dev=True) + + response = self.client.post(f"/user/notifications/tokens/ios/{self.token}/") + self.assertEqual(200, response.status_code) + self.assertEqual(1, IOSNotificationToken.objects.all().count()) + the_token = IOSNotificationToken.objects.first() + self.assertEqual(self.token, the_token.token) + self.assertEqual(self.test_user, the_token.user) + self.assertEqual(False, the_token.is_dev) + + def test_delete_token(self): + response = self.client.post(f"/user/notifications/tokens/ios/{self.token}/") + self.assertEqual(201, response.status_code) + self.assertEqual(1, IOSNotificationToken.objects.all().count()) + + response = self.client.delete(f"/user/notifications/tokens/ios/{self.token}/") + self.assertEqual(200, response.status_code) + self.assertEqual(0, IOSNotificationToken.objects.all().count()) + + +class TestNotificationService(TransactionTestCase): """Tests for CRUD Notification Settings""" def setUp(self): @@ -109,110 +92,38 @@ def setUp(self): self.client = APIClient() self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") self.client.force_authenticate(user=self.test_user) - initialize_b2b() - def test_get_settings(self): - # test that settings visible via GET + def test_get_services(self): + # test that services visible via GET response = self.client.get("/user/notifications/services/") res_json = json.loads(response.content) - self.assertEqual(2, len(res_json)) - - def test_invalid_settings_update(self): - NotificationToken.objects.all().delete() - payload = {"kind": "IOS", "token": "test123"} - response = self.client.post("/user/notifications/tokens/", payload) - res_json = json.loads(response.content) - - response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") - res_json = json.loads(response.content) - settings_id = res_json["id"] - payload = {"service": "PENN_MOBILE", "enabled": True} - response = self.client.patch(f"/user/notifications/settings/{settings_id}/", payload) - res_json = json.loads(response.content) - self.assertEqual(res_json["service"], "PENN_MOBILE") - self.assertTrue(res_json["enabled"]) + self.assertEqual(["OHQ", "PENN_MOBILE"], sorted(res_json)) - def test_valid_settings_update(self): - NotificationToken.objects.all().delete() - response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") - res_json = json.loads(response.content) - self.assertFalse(res_json["enabled"]) - - payload = {"kind": "IOS", "token": "test123"} - response = self.client.post("/user/notifications/tokens/", payload) - res_json = json.loads(response.content) - - response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") - res_json = json.loads(response.content) - settings_id = res_json["id"] - payload = {"service": "OHQ", "enabled": True} - response = self.client.patch(f"/user/notifications/settings/{settings_id}/", payload) - self.assertEqual(response.status_code, 400) - - def test_create_update_check_settings(self): - # test that invalid settings are rejected - NotificationSetting.objects.filter(service="PENN_MOBILE").delete() - payload = {"service": "Penn Mobile", "enabled": True} - response = self.client.post("/user/notifications/settings/", payload) - res_json = json.loads(response.content) - self.assertNotEqual(res_json, payload) - - # test that settings can be created - payload = {"service": "PENN_MOBILE", "enabled": True} - response = self.client.post("/user/notifications/settings/", payload) - res_json = json.loads(response.content) - self.assertEqual(res_json["service"], "PENN_MOBILE") - self.assertTrue(res_json["enabled"]) - - # test fail of re-creating settings - response = self.client.post("/user/notifications/settings/", payload) - self.assertEqual(response.status_code, 400) - - # since token empty, should still return false - response = self.client.get("/user/notifications/tokens/") + def test_get_settings(self): + response = self.client.get("/user/notifications/settings/") res_json = json.loads(response.content) - token_id = res_json[0]["id"] + self.assertDictEqual({"PENN_MOBILE": False, "OHQ": False}, res_json) - # update token to nonempty value - payload = {"kind": "IOS", "token": "test123"} - response = self.client.put(f"/user/notifications/tokens/{token_id}/", payload) - res_json = json.loads(response.content) - self.assertEqual("test123", res_json["token"]) - self.assertEqual(1, NotificationToken.objects.all().count()) + def test_update_settings(self): + response = self.client.put( + "/user/notifications/settings/", + json.dumps({"PENN_MOBILE": True}), + content_type="application/json", + ) + self.assertEqual(200, response.status_code) - # re-request check - response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") + response = self.client.get("/user/notifications/settings/") res_json = json.loads(response.content) - self.assertTrue(res_json["enabled"]) - - def test_check_fail(self): - # since invalid setting, should return error - response = self.client.get("/user/notifications/settings/PENN_MOBIL/check/") - self.assertEqual(response.status_code, 400) + self.assertDictEqual({"PENN_MOBILE": True, "OHQ": False}, res_json) - # def test_b2b_queryset_empty(self): - # self.client.logout() - # b2b_client = get_b2b_client() - # response = b2b_client.get("/user/notifications/settings/") - # self.assertEqual(response.status_code, 200) - # res_json = json.loads(response.content) - # self.assertEqual(0, len(res_json)) - - # def test_b2b_check(self): - # self.client.logout() - # b2b_client = get_b2b_client() - # response = b2b_client.get( - # "/user/notifications/settings/PENN_MOBILE/check/?pennkey=user" - # ) - # self.assertEqual(response.status_code, 200) - # res_json = json.loads(response.content) - # self.assertEqual(res_json["service"], "PENN_MOBILE") - # self.assertFalse(res_json["enabled"]) - - def test_b2b_auth_fails(self): - self.client.logout() - response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/?pennkey=user") - self.assertEqual(response.status_code, 403) + def test_invalid_settings_update(self): + # Requires TransactionTestCase since relies on database rollback + response = self.client.put( + "/user/notifications/settings/", + json.dumps({"UNKNOWN": True, "sPENN_MOBILE": True, "ABC": True, "OHQ": True}), + content_type="application/json", + ) + self.assertEqual(400, response.status_code) class TestNotificationAlert(TestCase): @@ -221,31 +132,26 @@ class TestNotificationAlert(TestCase): def setUp(self): self.client = APIClient() - # create user1 - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client.force_authenticate(user=self.test_user) - token_obj = NotificationToken.objects.get(user=self.test_user) - token_obj.token = "test123" - token_obj.save() + NotificationService.objects.bulk_create( + [ + NotificationService(name="PENN_MOBILE"), + NotificationService(name="OHQ"), + ] + ) - # create user2 - self.test_user = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") - self.client.force_authenticate(user=self.test_user) - token_obj = NotificationToken.objects.get(user=self.test_user) - token_obj.token = "test234" - token_obj.save() - setting = NotificationSetting.objects.get(token=token_obj, service="PENN_MOBILE") - setting.enabled = True - setting.save() + user1 = User.objects.create_user("user", "user@seas.upenn.edu", "user") + IOSNotificationToken.objects.create(user=user1, token="test123") + + user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + IOSNotificationToken.objects.create(user=user2, token="test234") + user2.notificationservice_set.add("PENN_MOBILE") # create user3 user3 = User.objects.create_user("user3", "user3@seas.upenn.edu", "user3") - token_obj = NotificationToken.objects.get(user=user3) - token_obj.token = "test234" - token_obj.save() - setting = NotificationSetting.objects.get(token=token_obj, service="PENN_MOBILE") - setting.enabled = True - setting.save() + IOSNotificationToken.objects.create(user=user3, token="test345") + user3.notificationservice_set.add("PENN_MOBILE") + + self.client.force_authenticate(user=user3) initialize_b2b() @@ -281,112 +187,72 @@ def test_single_notif(self): self.assertEqual(1, len(res_json["success_users"])) self.assertEqual(0, len(res_json["failed_users"])) - @mock.patch("user.notifications.get_client", mock_client) - def test_batch_notif(self): - # update all settings to be enabled - NotificationSetting.objects.all().update(enabled=True) - - # test notif - payload = { - "users": ["user2", "user1", "user3"], - "title": "Test", - "body": ":D", - "service": "PENN_MOBILE", - } - response = self.client.post( - "/user/notifications/alerts/", json.dumps(payload), content_type="application/json" - ) - res_json = json.loads(response.content) - self.assertEqual(1, len(res_json["success_users"])) - self.assertEqual(0, len(res_json["failed_users"])) - - # @mock.patch("user.notifications.get_client", mock_client) - # def test_b2b_batch_alert(self): - # self.client.logout() - # b2b_client = get_b2b_client() - # payload = { - # "users": ["user", "user2", "user3"], - # "title": "Test", - # "body": ":D", - # "service": "PENN_MOBILE", - # } - # response = b2b_client.post( - # "/user/notifications/alerts/", - # json.dumps(payload), - # content_type="application/json", - # ) - # res_json = json.loads(response.content) - # self.assertEqual(2, len(res_json["success_users"])) - # self.assertEqual(1, len(res_json["failed_users"])) - - -class TestSendGSRReminders(TestCase): - """Test Sending GSR Reminders""" - - def setUp(self): - call_command("load_gsrs") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client.force_authenticate(user=self.test_user) - - # enabling tokens and settings - token_obj = NotificationToken.objects.get(user=self.test_user) - token_obj.token = "test123" - token_obj.save() - - setting = NotificationSetting.objects.get( - token=token_obj, service=NotificationSetting.SERVICE_GSR_BOOKING - ) - setting.enabled = True - setting.save() - - # creating reservation and booking for notifs - g = GSRBooking.objects.create( - user=self.test_user, - gsr=GSR.objects.all().first(), - room_id=1, - room_name="Room", - start=timezone.now() + datetime.timedelta(minutes=5), - end=timezone.now() + datetime.timedelta(minutes=35), - ) - - r = Reservation.objects.create( - start=g.start, - end=g.end, - creator=self.test_user, - ) - - g.reservation = r - g.save() - - @mock.patch("user.notifications.get_client", mock_client) - def test_send_reminder(self): - call_command("send_gsr_reminders") - r = Reservation.objects.all().first() - self.assertTrue(r.reminder_sent) - - def test_send_reminder_no_gsrs(self): - GSRBooking.objects.all().delete() - call_command("send_gsr_reminders") - r = Reservation.objects.all().first() - self.assertFalse(r.reminder_sent) - - -class TestSendShadowNotifs(TestCase): - """Test Sending Shadow Notifications""" - - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client.force_authenticate(user=self.test_user) - token_obj = NotificationToken.objects.get(user=self.test_user) - token_obj.token = "test123" - token_obj.save() - - @mock.patch("user.notifications.get_client", mock_client) - def test_shadow_notifications(self): - # call command on every user - call_command("send_shadow_notifs", "yes", '{"test":"test"}') - # call command on specific set of users - call_command("send_shadow_notifs", "no", '{"test":"test"}', users="user1") +# TODO: FIX IN LATER PR + +# class TestSendGSRReminders(TestCase): +# """Test Sending GSR Reminders""" + +# def setUp(self): +# call_command("load_gsrs") +# self.client = APIClient() +# user = User.objects.create_user("user", "user@seas.upenn.edu", "user") +# user.iosnotificationtoken_set.create(token="test123") + + +# setting = NotificationSetting.objects.get( +# token=token_obj, service=NotificationSetting.SERVICE_GSR_BOOKING +# ) +# setting.enabled = True +# setting.save() + +# # creating reservation and booking for notifs +# g = GSRBooking.objects.create( +# user=self.test_user, +# gsr=GSR.objects.all().first(), +# room_id=1, +# room_name="Room", +# start=timezone.now() + datetime.timedelta(minutes=5), +# end=timezone.now() + datetime.timedelta(minutes=35), +# ) + +# r = Reservation.objects.create( +# start=g.start, +# end=g.end, +# creator=self.test_user, +# ) + +# g.reservation = r +# g.save() + +# @mock.patch("user.notifications.get_client", mock_client) +# def test_send_reminder(self): +# call_command("send_gsr_reminders") +# r = Reservation.objects.all().first() +# self.assertTrue(r.reminder_sent) + +# def test_send_reminder_no_gsrs(self): +# GSRBooking.objects.all().delete() +# call_command("send_gsr_reminders") +# r = Reservation.objects.all().first() +# self.assertFalse(r.reminder_sent) + + +# class TestSendShadowNotifs(TestCase): +# """Test Sending Shadow Notifications""" + +# def setUp(self): +# self.client = APIClient() +# self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") +# self.client.force_authenticate(user=self.test_user) +# token_obj = IOSNotificationToken.objects.get(user=self.test_user) +# token_obj.token = "test123" +# token_obj.save() + +# @mock.patch("user.notifications.get_client", mock_client) +# def test_shadow_notifications(self): +# # call command on every user +# call_command("send_shadow_notifs", "yes", '{"test":"test"}') + +# # call command on specific set of users +# call_command("send_shadow_notifs", "no", '{"test":"test"}', users="user1") diff --git a/backend/user/admin.py b/backend/user/admin.py index ba7ca5e2..2858523a 100644 --- a/backend/user/admin.py +++ b/backend/user/admin.py @@ -1,10 +1,12 @@ from django.contrib import admin -from user.models import IOSNotificationToken, AndroidNotificationToken, NotificationService, Profile +from user.models import AndroidNotificationToken, IOSNotificationToken, NotificationService, Profile + # custom IOSNotificationToken admin class IOSNotificationTokenAdmin(admin.ModelAdmin): - list_display = ('token', 'user', 'is_dev') + list_display = ("token", "user", "is_dev") + admin.site.register(IOSNotificationToken, IOSNotificationTokenAdmin) admin.site.register(AndroidNotificationToken) diff --git a/backend/user/models.py b/backend/user/models.py index 08789f7c..aa113389 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -13,51 +13,23 @@ class NotificationToken(models.Model): 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) + 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 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"), -# ) - - -# service = models.CharField(max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE) -# enabled = models.BooleanField(default=True) - class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) diff --git a/backend/user/notifications.py b/backend/user/notifications.py index aafe9b44..f91c929c 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -19,12 +19,10 @@ collections.MutableMapping = abc.MutableMapping from apns2.client import APNsClient -from apns2.credentials import TokenCredentials from apns2.payload import Payload from celery import shared_task - # taken from the apns2 method for batch notifications Notification = collections.namedtuple("Notification", ["token", "payload"]) @@ -52,8 +50,6 @@ def send_push_notifications(tokens, category, title, body, delay=0, is_dev=False send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow) - - @shared_task(name="notifications.send_immediate_notifications") def send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow): client = get_client(is_dev) @@ -81,21 +77,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 diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 440537f8..c9381ab5 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -3,6 +3,7 @@ from user.models import Profile + # class NotificationSettingSerializer(serializers.ModelSerializer): # class Meta: # model = NotificationSetting diff --git a/backend/user/urls.py b/backend/user/urls.py index 6667d63e..3c069309 100644 --- a/backend/user/urls.py +++ b/backend/user/urls.py @@ -2,10 +2,10 @@ from rest_framework import routers from user.views import ( + AndroidNotificationTokenView, ClearCookiesView, - NotificationAlertView, IOSNotificationTokenView, - AndroidNotificationTokenView, + NotificationAlertView, NotificationServiceSettingView, NotificationServiceView, UserView, @@ -19,8 +19,14 @@ additional_urls = [ path("notifications/tokens/ios//", IOSNotificationTokenView.as_view(), name="ios-token"), - path("notifications/tokens/android//", AndroidNotificationTokenView.as_view(), name="android-token"), - path("notifications/settings/", NotificationServiceSettingView.as_view(), name="notif-settings"), + path( + "notifications/tokens/android//", + 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"), diff --git a/backend/user/views.py b/backend/user/views.py index f4843ce3..6f559794 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -1,20 +1,17 @@ +from abc import ABC + from django.contrib.auth import get_user_model +from django.db import transaction from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404 from identity.permissions import B2BPermission -from rest_framework import generics, viewsets -from rest_framework.decorators import action +from rest_framework import generics from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from user.models import IOSNotificationToken, AndroidNotificationToken, NotificationService +from user.models import AndroidNotificationToken, IOSNotificationToken, NotificationService from user.notifications import send_push_notifications -from user.serializers import ( - UserSerializer, -) - -from abc import ABC +from user.serializers import UserSerializer User = get_user_model() @@ -45,29 +42,34 @@ class NotificationTokenView(APIView, ABC): queryset = None def get_defaults(self): - raise NotImplementedError # pragma: no cover + raise NotImplementedError # pragma: no cover def post(self, request, token): _, created = self.queryset.update_or_create(token=token, defaults=self.get_defaults()) if created: return Response({"detail": "Token created."}, status=201) - return Response({"detail": "Token updated."}, status=200) + return Response({"detail": "Token updated."}) def delete(self, request, token): self.queryset.filter(token=token).delete() - return Response({"detail": "Token deleted."}, status=200) + return Response({"detail": "Token deleted."}) + class IOSNotificationTokenView(NotificationTokenView): queryset = IOSNotificationToken.objects.all() + def get_defaults(self): - is_dev = self.request.data.get("is_dev", "false").lower() == "true" + is_dev = self.request.data.get("is_dev", False) return {"user": self.request.user, "is_dev": is_dev} + class AndroidNotificationTokenView(NotificationTokenView): queryset = AndroidNotificationToken.objects.all() + def get_defaults(self): return {"user": self.request.user} + class NotificationServiceSettingView(APIView): permission_classes = [IsAuthenticated] @@ -81,6 +83,27 @@ def get(self, request): } ) + def put(self, request): + user = request.user + settings = request.data + if not isinstance(settings, dict) or not all( + isinstance(value, bool) for value in settings.values() + ): + return Response({"detail": "Invalid request"}, status=400) + + try: + with transaction.atomic(): + user.notificationservice_set.add( + *[service for service, enabled in settings.items() if enabled] + ) + user.notificationservice_set.remove( + *[service for service, enabled in settings.items() if not enabled] + ) + except Exception as e: + return Response({"detail": str(e)}, status=400) + return Response({"detail": "Settings updated."}) + + class NotificationServiceView(APIView): permission_classes = [IsAuthenticated] @@ -108,27 +131,33 @@ def post(self, request): title = request.data.get("title") body = request.data.get("body") delay = max(request.data.get("delay", 0), 0) - is_dev = request.data.get("is_dev", "false").lower() == "true" + is_dev = request.data.get("is_dev", False) if None in [service, title, body]: return Response({"detail": "Missing required parameters."}, status=400) if not (service_obj := NotificationService.objects.filter(name=service).first()): return Response({"detail": "Invalid service."}, status=400) - - users_with_service = service_obj.enabled_users.filter(username__in=usernames) - tokens = list(IOSNotificationToken.objects.filter(user__in=users_with_service, is_dev=is_dev).values_list( - "token", flat=True - )) + users_with_service = service_obj.enabled_users.filter(username__in=usernames) - send_push_notifications( - tokens, service, title, body, delay, is_dev + tokens = list( + IOSNotificationToken.objects.filter( + user__in=users_with_service, is_dev=is_dev + ).values_list("token", flat=True) ) + if tokens: + send_push_notifications(tokens, service, title, body, delay, is_dev) + users_with_service_usernames = users_with_service.values_list("username", flat=True) users_not_reached_usernames = list(set(usernames) - set(users_with_service_usernames)) - return Response({"success_users": users_with_service_usernames, "failed_users": users_not_reached_usernames}) + return Response( + { + "success_users": users_with_service_usernames, + "failed_users": users_not_reached_usernames, + } + ) class ClearCookiesView(APIView): diff --git a/k8s/main.ts b/k8s/main.ts index f8b62811..55993bac 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -82,13 +82,13 @@ export class MyChart extends PennLabsChart { env: [{ name: "DJANGO_SETTINGS_MODULE", value: "pennmobile.settings.production" }] }); - new CronJob(this, 'send-gsr-reminders', { - schedule: "20,50 * * * *", - image: backendImage, - secret, - cmd: ["python", "manage.py", "send_gsr_reminders"], - env: [{ name: "DJANGO_SETTINGS_MODULE", value: "pennmobile.settings.production" }] - }); + // new CronJob(this, 'send-gsr-reminders', { + // schedule: "20,50 * * * *", + // image: backendImage, + // secret, + // cmd: ["python", "manage.py", "send_gsr_reminders"], + // env: [{ name: "DJANGO_SETTINGS_MODULE", value: "pennmobile.settings.production" }] + // }); new CronJob(this, 'get-fitness-snapshot', { schedule: cronTime.every(3).hours(), From b04eafa4080c53d7641556b01c4cb5e70297bf19 Mon Sep 17 00:00:00 2001 From: vcai122 Date: Sun, 17 Nov 2024 00:22:24 -0500 Subject: [PATCH 5/5] remove comments and notif param cleaner --- backend/user/notifications.py | 5 +++-- backend/user/serializers.py | 20 -------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/backend/user/notifications.py b/backend/user/notifications.py index f91c929c..e4ffa033 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -43,11 +43,12 @@ def send_push_notifications(tokens, category, title, body, delay=0, is_dev=False # 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, category, is_dev, is_shadow, delay) + send_delayed_notifications(*params, delay=delay) else: - send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow) + send_immediate_notifications(*params) @shared_task(name="notifications.send_immediate_notifications") diff --git a/backend/user/serializers.py b/backend/user/serializers.py index c9381ab5..3e57c7d6 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -4,26 +4,6 @@ from user.models import Profile -# 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) - - class ProfileSerializer(serializers.ModelSerializer): class Meta: model = Profile