diff --git a/.gitignore b/.gitignore index 18e47145c08..f8181dea159 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,10 @@ package-lock.json # Virtual environments venv/ .venv/ + +# Webpush +/keys/webpush/ +/keys/ + +# Keys +*.pem \ No newline at end of file diff --git a/config/docker/initial_setup.sh b/config/docker/initial_setup.sh index 3786433fc8b..e3c22fc6716 100755 --- a/config/docker/initial_setup.sh +++ b/config/docker/initial_setup.sh @@ -49,3 +49,6 @@ python3 -u manage.py import_sports $(date +%m) echo -e "${BLUE}${BOLD}Creating CSL apps...${CLEAR}" python3 -u manage.py dev_create_cslapps + +echo -e "${BLUE}${BOLD}Generating vapid keys...${CLEAR}" +python3 create_vapid_keys.py \ No newline at end of file diff --git a/config/scripts/create_vapid_keys.py b/config/scripts/create_vapid_keys.py new file mode 100644 index 00000000000..546efa560db --- /dev/null +++ b/config/scripts/create_vapid_keys.py @@ -0,0 +1,25 @@ +import base64 +import os + +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from py_vapid import Vapid + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.makedirs(os.path.join(PROJECT_ROOT, "keys", "webpush")) + +# Generate VAPID key pair +vapid = Vapid() +vapid.generate_keys() + +# Get public and private keys for the vapid key pair +vapid.save_public_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "public_key.pem")) +public_key_bytes = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + +vapid.save_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "private_key.pem")) + + +# Convert the public key to applicationServerKey format +application_server_key = base64.urlsafe_b64encode(public_key_bytes).replace(b"=", b"").decode("utf8") + +with open(os.path.join(PROJECT_ROOT, "keys", "webpush", "ApplicationServerKey.key"), "w", encoding="utf-8") as f: + f.write(application_server_key) diff --git a/cron/eighth-absence.sh b/cron/eighth-absence.sh index 852e13149e7..4b55b2e7c01 100755 --- a/cron/eighth-absence.sh +++ b/cron/eighth-absence.sh @@ -4,4 +4,5 @@ timestamp=$(date +"%Y-%m-%d-%H%M") cd /usr/local/www/intranet3 ./cron/env.sh ./manage.py absence_email --silent -echo "Absence email sent at $timestamp." >> /var/log/ion/email.log +./cron/env.sh ./manage.py absence_notify --silent +echo "Absence email and push notification sent at $timestamp." >> /var/log/ion/email.log diff --git a/docs/sourcedoc/intranet.apps.eighth.management.commands.rst b/docs/sourcedoc/intranet.apps.eighth.management.commands.rst index 5e20fd893fe..87e2af46314 100644 --- a/docs/sourcedoc/intranet.apps.eighth.management.commands.rst +++ b/docs/sourcedoc/intranet.apps.eighth.management.commands.rst @@ -12,6 +12,14 @@ intranet.apps.eighth.management.commands.absence\_email module :undoc-members: :show-inheritance: +intranet.apps.eighth.management.commands.absence\_notify module +--------------------------------------------------------------- + +.. automodule:: intranet.apps.eighth.management.commands.absence_notify + :members: + :undoc-members: + :show-inheritance: + intranet.apps.eighth.management.commands.delete\_duplicate\_signups module -------------------------------------------------------------------------- diff --git a/docs/sourcedoc/intranet.apps.notifications.rst b/docs/sourcedoc/intranet.apps.notifications.rst index 3f5e527d432..5ff7bde7c27 100644 --- a/docs/sourcedoc/intranet.apps.notifications.rst +++ b/docs/sourcedoc/intranet.apps.notifications.rst @@ -4,6 +4,14 @@ intranet.apps.notifications package Submodules ---------- +intranet.apps.notifications.api module +-------------------------------------- + +.. automodule:: intranet.apps.notifications.api + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.emails module ----------------------------------------- @@ -12,6 +20,14 @@ intranet.apps.notifications.emails module :undoc-members: :show-inheritance: +intranet.apps.notifications.forms module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.forms + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.models module ----------------------------------------- @@ -20,6 +36,14 @@ intranet.apps.notifications.models module :undoc-members: :show-inheritance: +intranet.apps.notifications.serializers module +---------------------------------------------- + +.. automodule:: intranet.apps.notifications.serializers + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.tasks module ---------------------------------------- @@ -28,6 +52,14 @@ intranet.apps.notifications.tasks module :undoc-members: :show-inheritance: +intranet.apps.notifications.tests module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.tests + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.urls module --------------------------------------- @@ -36,6 +68,14 @@ intranet.apps.notifications.urls module :undoc-members: :show-inheritance: +intranet.apps.notifications.utils module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.utils + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.views module ---------------------------------------- diff --git a/docs/sourcedoc/intranet.apps.polls.rst b/docs/sourcedoc/intranet.apps.polls.rst index d5e430efb60..6854b5ff000 100644 --- a/docs/sourcedoc/intranet.apps.polls.rst +++ b/docs/sourcedoc/intranet.apps.polls.rst @@ -28,6 +28,14 @@ intranet.apps.polls.models module :undoc-members: :show-inheritance: +intranet.apps.polls.notifications module +---------------------------------------- + +.. automodule:: intranet.apps.polls.notifications + :members: + :undoc-members: + :show-inheritance: + intranet.apps.polls.tests module -------------------------------- diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index 2099248f7ef..273fd82f5cc 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -12,7 +12,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above." - self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + self.fields["notify_post"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will receive a " + "push notification." + ) self.fields["notify_email_all"].help_text = ( "This will send an email notification to all of the users who can see this post. This option " @@ -41,7 +46,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above." - self.fields["notify_post_resend"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + self.fields["notify_post_resend"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will " + "receive a push notification." + ) self.fields["notify_email_all_resend"].help_text = ( "This will resend an email notification to all of the users who can see this post. This option " @@ -105,7 +115,12 @@ class AnnouncementAdminForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + self.fields["notify_post"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will receive a " + "push notification." + ) self.fields["notify_email_all"].help_text = ( "This will send an email notification to all of the users who can see this post. This option " "does NOT take users' email notification preferences into account, so please use with care." diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py index 51e6d486842..84a2dddeaa8 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -7,12 +7,18 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.core import exceptions +from django.db.models import Q from django.urls import reverse +from django.utils.html import strip_tags +from push_notifications.models import WebPushDevice from requests_oauthlib import OAuth1 from sentry_sdk import capture_exception from ...utils.date import get_senior_graduation_year -from ..notifications.tasks import email_send_task +from ..notifications.tasks import email_send_task, send_bulk_notification +from ..notifications.utils import truncate_content, truncate_title +from ..users.models import User +from .models import Announcement logger = logging.getLogger(__name__) @@ -135,7 +141,7 @@ def announcement_posted_email(request, obj, send_all=False): emails.append(u.notification_email) users_send.append(u) - if not settings.PRODUCTION and len(emails) > 3: + if not settings.PRODUCTION and len(emails) > 3 and not settings.FORCE_EMAIL_SEND: raise exceptions.PermissionDenied("You're about to email a lot of people, and you aren't in production!") base_url = request.build_absolute_uri(reverse("index")) @@ -200,3 +206,27 @@ def notify_twitter(status): req = requests.post(url, data=data, auth=auth, timeout=15) return req.text + + +def announcement_posted_push_notification(obj: Announcement) -> None: + """Send a (Web)push notification to users when an announcement is posted. + + obj: The announcement object + + """ + + if not obj.groups.all(): + users = User.objects.filter(push_notification_preferences__announcement_notifications=True) + devices = WebPushDevice.objects.filter(user__in=users) + else: + users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__announcement_notifications=True)) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"Announcement: {truncate_title(obj.title)} ({obj.get_author()})", + body=truncate_content(strip_tags(obj.content_no_links)), + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("view_announcement", args=[obj.id]), + }, + ) diff --git a/intranet/apps/announcements/views.py b/intranet/apps/announcements/views.py index b1f92231e0d..5ae71a8bd3b 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -19,6 +19,7 @@ admin_request_announcement_email, announcement_approved_email, announcement_posted_email, + announcement_posted_push_notification, announcement_posted_twitter, request_announcement_email, ) @@ -48,6 +49,7 @@ def announcement_posted_hook(request, obj): """ if obj.notify_post: announcement_posted_twitter(request, obj) + announcement_posted_push_notification(obj) try: notify_all = obj.notify_email_all except AttributeError: diff --git a/intranet/apps/api/urls.py b/intranet/apps/api/urls.py index 2c14c24eb07..2daca45a4ec 100644 --- a/intranet/apps/api/urls.py +++ b/intranet/apps/api/urls.py @@ -4,6 +4,7 @@ from ..bus import api as bus_api from ..eighth.views import api as eighth_api from ..emerg import api as emerg_api +from ..notifications import api as notification_api from ..schedule import api as schedule_api from ..users import api as users_api from .views import api_root @@ -43,4 +44,15 @@ re_path(r"^/emerg$", emerg_api.emerg_status, name="api_emerg_status"), re_path(r"^/bus$", bus_api.RouteList.as_view(), name="api_bus_list"), re_path(r"^/bus/(?P\d+)$", bus_api.RouteDetail.as_view(), name="api_bus_detail"), + re_path( + r"^/notifications/webpush/application_key$", notification_api.GetApplicationServerKey.as_view(), name="api_get_vapid_application_server_key" + ), + re_path(r"^/notifications/webpush/subscribe$", notification_api.WebpushSubscribeDevice.as_view(), name="api_webpush_subscribe"), + re_path(r"^/notifications/webpush/unsubscribe$", notification_api.WebpushUnsubscribeDevice.as_view(), name="api_webpush_unsubscribe"), + re_path(r"^/notifications/webpush/update_subscription$", notification_api.WebpushUpdateDevice.as_view(), name="api_webpush_update_subscription"), + re_path( + r"^/notifications/webpush/subscription_status$", + notification_api.GetWebpushSubscriptionStatus.as_view(), + name="api_webpush_subscription_status", + ), ] diff --git a/intranet/apps/bus/tasks.py b/intranet/apps/bus/tasks.py index 2dc575783b8..46cd26e53d4 100644 --- a/intranet/apps/bus/tasks.py +++ b/intranet/apps/bus/tasks.py @@ -1,6 +1,12 @@ from celery import shared_task from celery.utils.log import get_task_logger +from django.urls import reverse +from django.utils import timezone +from ... import settings +from ..notifications.tasks import send_notification_to_user +from ..schedule.models import Day +from ..users.models import User from .models import Route logger = get_task_logger(__name__) @@ -12,3 +18,54 @@ def reset_routes() -> None: for route in Route.objects.all(): route.reset_status() + + +@shared_task +def push_bus_notifications(schedule: bool = False) -> None: + if schedule: + today = Day.objects.today() + + if today is not None: + dismissal = today.end_time + + if dismissal is not None: + block_datetime = dismissal.date_obj(timezone.now()) + block_datetime = timezone.make_aware(block_datetime, timezone.get_current_timezone()) + + push_bus_notifications.apply_async(eta=block_datetime) + logger.info("Push bus notifications scheduled at %s (bus info)", str(block_datetime)) + + else: + route_translations = {key: convert_dataset(value) for key, value in settings.PUSH_ROUTE_TRANSLATIONS.items()} + + users = User.objects.filter(push_notification_preferences__bus_notifications=True) + + for user in users: + if user.bus_route.status == "d": + send_notification_to_user( + user=user, + title="Bus Delayed", + body=f"Sorry, your bus ({user.bus_route.bus_number}) has been delayed.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + else: + space = user.bus_route.space + if space is not None: + for key, value in route_translations.items(): + if space in value: + send_notification_to_user( + user=user, + title="Bus Location", + body=f"Your bus is at the {key} of the parking lot.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + +def convert_dataset(dataset): + # Convert each number to the format "_number" and return as a set + # because that's how the ID spots are named + return {"_" + str(number) for number in dataset} diff --git a/intranet/apps/eighth/management/commands/absence_notify.py b/intranet/apps/eighth/management/commands/absence_notify.py new file mode 100644 index 00000000000..8c2b441863e --- /dev/null +++ b/intranet/apps/eighth/management/commands/absence_notify.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand + +from intranet.apps.eighth.models import EighthSignup +from intranet.apps.eighth.notifications import absence_notification + + +class Command(BaseCommand): + help = "Push notify users who have an Eighth Period absence (via Webpush.)" + + def add_arguments(self, parser): + parser.add_argument("--silent", action="store_true", dest="silent", default=False, help="Be silent.") + + parser.add_argument("--pretend", action="store_true", dest="pretend", default=False, help="Pretend, and don't actually do anything.") + + def handle(self, *args, **options): + log = not options["silent"] + + absences = EighthSignup.objects.get_absences().filter(absence_notified=False) + + for signup in absences: + if log: + self.stdout.write(str(signup)) + if not options["pretend"]: + absence_notification(signup) + signup.absence_notified = True + signup.save() + + if log: + self.stdout.write("Done.") diff --git a/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py new file mode 100644 index 00000000000..c4d02fdd164 --- /dev/null +++ b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-07-25 23:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0065_auto_20220903_0038'), + ] + + operations = [ + migrations.AddField( + model_name='eighthsignup', + name='absence_notified', + field=models.BooleanField(blank=True, default=False), + ), + migrations.AddField( + model_name='historicaleighthsignup', + name='absence_notified', + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py index 6fca981ebe8..73f7d481d50 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -1503,9 +1503,14 @@ def cancel(self): self.save(update_fields=["cancelled"]) if not self.is_both_blocks or self.block.block_letter != "B": - from .notifications import activity_cancelled_email # pylint: disable=import-outside-toplevel,cyclic-import + # pylint: disable=import-outside-toplevel,cyclic-import + from .notifications import ( + activity_cancelled_email, + activity_cancelled_notification, + ) activity_cancelled_email(self) + activity_cancelled_notification(self) def uncancel(self): """Uncancel an EighthScheduledActivity. @@ -1599,6 +1604,8 @@ class EighthSignup(AbstractBaseEighthModel): Whether the student has dismissed the absence notification. absence_emailed Whether the student has been emailed about the absence. + absence_notified + Whether the student has received a push notification about their absence """ objects = EighthSignupManager() @@ -1619,6 +1626,7 @@ class EighthSignup(AbstractBaseEighthModel): was_absent = models.BooleanField(default=False, blank=True) absence_acknowledged = models.BooleanField(default=False, blank=True) absence_emailed = models.BooleanField(default=False, blank=True) + absence_notified = models.BooleanField(default=False, blank=True) archived_was_absent = models.BooleanField(default=False, blank=True) diff --git a/intranet/apps/eighth/notifications.py b/intranet/apps/eighth/notifications.py index 738c475a039..6ff02fd5422 100644 --- a/intranet/apps/eighth/notifications.py +++ b/intranet/apps/eighth/notifications.py @@ -1,9 +1,11 @@ import logging from django.urls import reverse +from push_notifications.models import WebPushDevice +from ... import settings from ..notifications.emails import email_send -from ..notifications.tasks import email_send_task +from ..notifications.tasks import email_send_task, send_bulk_notification, send_notification_to_user from .models import EighthScheduledActivity, EighthSignup logger = logging.getLogger(__name__) @@ -69,7 +71,7 @@ def activity_cancelled_email(sched_act: EighthScheduledActivity): emails = list({signup.user.notification_email for signup in sched_act.eighthsignup_set.filter(user__receive_eighth_emails=True)}) - base_url = "https://ion.tjhsst.edu" + base_url = settings.PUSH_NOTIFICATIONS_BASE_URL data = {"sched_act": sched_act, "date_str": date_str, "base_url": base_url} @@ -93,7 +95,7 @@ def absence_email(signup, use_celery=True): # We can't build an absolute URI because this isn't being executed # in the context of a Django request - base_url = "https://ion.tjhsst.edu" # request.build_absolute_uri(reverse('index')) + base_url = settings.PUSH_NOTIFICATIONS_BASE_URL # request.build_absolute_uri(reverse('index')) data = { "user": user, @@ -109,3 +111,31 @@ def absence_email(signup, use_celery=True): return None else: return email_send(*args) + + +def activity_cancelled_notification(sched_act: EighthScheduledActivity): + date_str = sched_act.block.date.strftime("%A, %B %-d") + devices = WebPushDevice.objects.filter(user__in=sched_act.members.all()) + + send_bulk_notification.delay( + filtered_objects=devices, + title="Eighth Period Activity Cancelled", + body=f"The activity '{sched_act.activity.name}' was cancelled on {date_str} " + f"for {sched_act.block.block_letter}. You will need to select a new activity", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[sched_act.block.id]), + }, + ) + + +def absence_notification(signup: EighthSignup): + user = signup.user + if user.push_notification_preferences.is_subscribed: + send_notification_to_user.delay( + user=signup.user, + title="Eighth Period Absence", + body=f"You received an Eighth Period absence on {signup.scheduled_activity.block}", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_absences"), + }, + ) diff --git a/intranet/apps/eighth/tasks.py b/intranet/apps/eighth/tasks.py index 20d4326e92c..a285b30fd7f 100644 --- a/intranet/apps/eighth/tasks.py +++ b/intranet/apps/eighth/tasks.py @@ -1,18 +1,23 @@ import calendar import datetime -from typing import Collection +from typing import Any, Collection, List from celery import shared_task from celery.utils.log import get_task_logger from django.conf import settings from django.contrib.auth import get_user_model from django.core.mail import EmailMessage +from django.urls import reverse from django.utils import timezone +from push_notifications.models import WebPushDevice from ...utils.helpers import join_nicely from ..groups.models import Group from ..notifications.emails import email_send -from .models import EighthActivity, EighthRoom, EighthScheduledActivity +from ..notifications.tasks import send_bulk_notification, send_notification_to_user +from ..schedule.models import Day +from ..users.models import User +from .models import EighthActivity, EighthBlock, EighthRoom, EighthScheduledActivity logger = get_task_logger(__name__) @@ -343,3 +348,103 @@ def follow_up_absence_emails(): [student.notification_email], bcc=True, ) + + +@shared_task +def push_eighth_reminder_notifications(schedule: bool = False) -> None: + """Send push notification reminders to sign up, specified number of minutes prior to blocks locking""" + if schedule: + block = EighthBlock.objects.get_blocks_today().first() + + if block is not None: + # Get the time to send reminder notifications (PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES + # minutes prior to the block locking) + block_datetime = datetime.datetime.combine(timezone.now(), block.signup_time) + block_datetime = timezone.make_aware(block_datetime, timezone.get_current_timezone()) + notification_datetime = block_datetime - datetime.timedelta(minutes=settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES) + + push_eighth_reminder_notifications.apply_async(eta=notification_datetime) + logger.info("Push reminder notifications scheduled at %s for %s block (eighth reminder)", str(notification_datetime), block.block_letter) + + else: + todays_blocks = EighthBlock.objects.get_blocks_today() + + if todays_blocks is not None: + for block in todays_blocks: + unsigned_students = block.get_unsigned_students() + + # We only want to send this notification to users who have enabled "eighth_reminder_notifications" + # in their preferences. + users_to_send = unsigned_students.filter(push_notification_preferences__eighth_reminder_notifications=True) + + # No need to check if the user is subscribed since we are passing WebPushDevice objects directly + devices_to_send = WebPushDevice.objects.filter(user__in=users_to_send) + + send_bulk_notification( + filtered_objects=devices_to_send, + title="Sign up for Eighth Period", + body=f"You have not signed up for today's eighth period ({block.block_letter} block). " + f"Sign ups close in {settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} minutes.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[block.id]), + }, + ) + + +@shared_task +def push_glance_notifications(schedule: bool = False) -> None: + if schedule: + today_8 = Day.objects.today().day_type.blocks.filter(name__contains="8") + if today_8: + timezone_now = timezone.now().today() + first_start_time = datetime.time(today_8[0].start.hour, today_8[0].start.minute) + last_start_time = datetime.time(today_8.last().start.hour, today_8.last().start.minute) + first_start_date = datetime.datetime.combine(timezone_now, first_start_time) + last_start_date = datetime.datetime.combine(timezone_now, last_start_time) + if ( + first_start_date - datetime.timedelta(minutes=30) + < datetime.datetime.combine(timezone_now, timezone.now().time()) + < last_start_date + datetime.timedelta(minutes=20) + ): + first_start_date = timezone.make_aware(first_start_date, timezone.get_current_timezone()) + + push_glance_notifications.apply_async(eta=first_start_date) + logger.info("Push glance notifications scheduled at %s (glance)", str(first_start_date)) + else: + users_to_send = User.objects.filter(push_notification_preferences__glance_notifications=True) + blocks = EighthBlock.objects.get_blocks_today() + + if blocks: + for user in users_to_send: + sch_acts = [] + for b in blocks: + try: + act = user.eighthscheduledactivity_set.get(block=b) + if act.activity.name != "z - Hybrid Sticky": + sch_acts.append( + [b, act, ", ".join([r.name for r in act.get_true_rooms()]), ", ".join([s.name for s in act.get_true_sponsors()])] + ) + except EighthScheduledActivity.DoesNotExist: + sch_acts.append([b, None]) + + body = "\n".join( + [ + f"{s[0].hybrid_text if list_index_exists(0, s) else None} block: " + f"{s[1].full_title if list_index_exists(1, s) else None} " + f"(Room {s[2] if list_index_exists(2, s) else None})" + for s in sch_acts + ] + ) + + send_notification_to_user( + user=user, + title="Eighth Period Glance", + body=body, + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_location"), + }, + ) + + +def list_index_exists(index: int, list_to_check: List[Any]) -> bool: + return len(list_to_check) > index and list_to_check[index] diff --git a/intranet/apps/notifications/api.py b/intranet/apps/notifications/api.py new file mode 100644 index 00000000000..2a624b6d671 --- /dev/null +++ b/intranet/apps/notifications/api.py @@ -0,0 +1,78 @@ +import os + +from push_notifications.models import WebPushDevice +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from intranet.apps.notifications.serializers import WebPushDeviceSerializer + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + + +class GetApplicationServerKey(APIView): + def get(self, request): + # Load the VAPID application server key from a file + file_path = os.path.join(PROJECT_ROOT, "keys", "webpush", "ApplicationServerKey.key") + with open(file_path, encoding="utf-8") as file: + server_key = file.read().strip() + return Response({"applicationServerKey": server_key}, status=status.HTTP_200_OK) + + +class GetWebpushSubscriptionStatus(APIView): + def post(self, request): + endpoint = request.data.get("endpoint") + try: + subscription = WebPushDevice.objects.filter(registration_id=endpoint).first() + except WebPushDevice.DoesNotExist: + return Response({"status": False}, status=status.HTTP_200_OK) + if subscription is not None and subscription.active: + return Response({"status": True}, status=status.HTTP_200_OK) + else: + return Response({"status": False}, status=status.HTTP_200_OK) + + +class WebpushSubscribeDevice(generics.CreateAPIView): + queryset = WebPushDevice.objects.all() + serializer_class = WebPushDeviceSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class WebpushUpdateDevice(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + old_registration_id = request.data.get("old_registration_id") + + try: + subscription = WebPushDevice.objects.filter(registration_id=old_registration_id).first() + subscription.registration_id = request.data.get("registration_id") + subscription.p256dh = request.data.get("p256dh") + subscription.auth = request.data.get("auth") + subscription.save() + + return Response({"message": "Subscription updated"}, status=status.HTTP_200_OK) + except WebPushDevice.DoesNotExist: + return Response({"error": "Subscription not found"}, status=status.HTTP_404_NOT_FOUND) + + +class WebpushUnsubscribeDevice(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + endpoint = request.data.get("endpoint") + + try: + subscription = WebPushDevice.objects.filter(registration_id=endpoint).first() + subscription.delete() + # Check if the user no longer has any (0) subscribed devices left + if WebPushDevice.objects.filter(user=request.user).count() == 0: + request.user.push_notification_preferences.is_subscribed = False + else: + request.user.push_notification_preferences.is_subscribed = True + return Response({"message": "Subscription deleted"}, status=status.HTTP_200_OK) + except WebPushDevice.DoesNotExist: + return Response({"error": "Subscription not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/intranet/apps/notifications/forms.py b/intranet/apps/notifications/forms.py new file mode 100644 index 00000000000..eed15500cd1 --- /dev/null +++ b/intranet/apps/notifications/forms.py @@ -0,0 +1,15 @@ +from django import forms + +from intranet.apps.groups.models import Group +from intranet.apps.users.models import User + + +class SendPushNotificationForm(forms.Form): + title = forms.CharField(max_length=50) + body = forms.CharField( + max_length=200, + widget=forms.Textarea(attrs={"rows": 4, "cols": 40}), + ) + url = forms.URLField(initial="https://ion.tjhsst.edu") + users = forms.ModelMultipleChoiceField(queryset=User.objects.all(), required=False) + groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), required=False) diff --git a/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py b/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py new file mode 100644 index 00000000000..f7d75a54ad7 --- /dev/null +++ b/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.25 on 2024-07-24 17:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0007_auto_20151221_2259'), + ] + + operations = [ + migrations.CreateModel( + name='UserPushNotificationPreferences', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('eighth_reminder_notifications', models.BooleanField(default=True, verbose_name='Eighth Period Reminder Notifications')), + ('eighth_waitlist_notifications', models.BooleanField(default=False, verbose_name='Eighth Period Waitlist Notifications')), + ('glance_notifications', models.BooleanField(default=False, verbose_name='Eighth Period Glance Notification')), + ('announcement_notifications', models.BooleanField(default=True, verbose_name='Announcement Notifications')), + ('poll_notifications', models.BooleanField(default=True, verbose_name='Poll Notifications')), + ('silent_notifications', models.BooleanField(default=False, verbose_name='Silent')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='push_notification_preferences', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py b/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py new file mode 100644 index 00000000000..02faa1c15f9 --- /dev/null +++ b/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2024-07-25 13:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0008_userpushnotificationpreferences'), + ] + + operations = [ + migrations.AddField( + model_name='userpushnotificationpreferences', + name='is_subscribed', + field=models.BooleanField(default=False), + ), + ] diff --git a/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py b/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py new file mode 100644 index 00000000000..5cf0c7f2e65 --- /dev/null +++ b/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2024-07-25 23:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0009_userpushnotificationpreferences_is_subscribed'), + ] + + operations = [ + migrations.RemoveField( + model_name='userpushnotificationpreferences', + name='silent_notifications', + ), + ] diff --git a/intranet/apps/notifications/migrations/0011_webpushnotification.py b/intranet/apps/notifications/migrations/0011_webpushnotification.py new file mode 100644 index 00000000000..cbbf1283441 --- /dev/null +++ b/intranet/apps/notifications/migrations/0011_webpushnotification.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2024-07-27 23:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('push_notifications', '0010_alter_gcmdevice_options_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0010_remove_userpushnotificationpreferences_silent_notifications'), + ] + + operations = [ + migrations.CreateModel( + name='WebPushNotification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_sent', models.DateTimeField(auto_now=True)), + ('target', models.CharField(choices=[('user', 'User'), ('device', 'Single Device'), ('device_queryset', 'Device Queryset (Multiple Devices)')], max_length=15)), + ('title', models.TextField()), + ('body', models.TextField()), + ('device_queryset_sent', models.ManyToManyField(blank=True, to='push_notifications.WebPushDevice')), + ('device_sent', models.ForeignKey(blank=True, default='Deleted Device', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='notifications_device_sent', to='push_notifications.webpushdevice')), + ('user_sent', models.ForeignKey(blank=True, default='Deleted User', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='notifications_user_sent', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py b/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py new file mode 100644 index 00000000000..fb15f77f13f --- /dev/null +++ b/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2024-07-30 23:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('push_notifications', '0010_alter_gcmdevice_options_and_more'), + ('notifications', '0011_webpushnotification'), + ] + + operations = [ + migrations.AddField( + model_name='userpushnotificationpreferences', + name='bus_notifications', + field=models.BooleanField(default=False, verbose_name='Bus Notifications'), + ), + migrations.AlterField( + model_name='webpushnotification', + name='device_sent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications_device_sent', to='push_notifications.webpushdevice'), + ), + migrations.AlterField( + model_name='webpushnotification', + name='user_sent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications_user_sent', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py b/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py new file mode 100644 index 00000000000..f5b1c025e98 --- /dev/null +++ b/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.25 on 2024-08-18 23:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0012_auto_20240730_1928'), + ] + + operations = [ + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='bus_notifications', + field=models.BooleanField(default=True, verbose_name='Bus Notifications'), + ), + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='eighth_waitlist_notifications', + field=models.BooleanField(default=True, verbose_name='Eighth Period Waitlist Notifications'), + ), + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='glance_notifications', + field=models.BooleanField(default=True, verbose_name='Eighth Period Glance Notification'), + ), + ] diff --git a/intranet/apps/notifications/models.py b/intranet/apps/notifications/models.py index b5251ff2841..e6db2bc8f92 100644 --- a/intranet/apps/notifications/models.py +++ b/intranet/apps/notifications/models.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import models +from push_notifications.models import WebPushDevice class NotificationConfig(models.Model): @@ -39,3 +40,75 @@ def data(self): if json_data and "data" in json_data: return json_data["data"] return {} + + +class UserPushNotificationPreferences(models.Model): + """Represents a user's preferences for (Web)push notifications + By default, subscribing to notifications enrolls the user for + eighth absence and scheduling conflict (i.e. cancelled activity) notifications. + Attributes: + user + The :class:`User` who has + subscribed to notifications. + eighth_reminder_notifications + Whether the user wants to receive eighth period reminder + notifications to sign up if they haven't already + signed up within settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES + minutes of the blocks locking + eighth_waitlist_notifications + Whether the user wants to receive notifications if using the + waitlist. This is currently not in use (waitlist is disabled) + glance_notifications + Whether the user wants to receive their eighth period "glance" + as a notification (it shows what blocks they've signed up for) + announcement_notifications + Whether the user wants to receive notifications when a new + Ion announcement is posted + poll_notifications + Whether the user wants to receive a notification when a poll + they can vote in opens + bus_notifications + Whether the user wants to receive notifications related to bus info + i.e. when and where their bus arrives or if their bus is late + is_subscribed + Set to true if the user has one or more devices subscribed to Webpush; + otherwise, false. + """ + + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="push_notification_preferences", on_delete=models.CASCADE) + eighth_reminder_notifications = models.BooleanField("Eighth Period Reminder Notifications", default=True) + eighth_waitlist_notifications = models.BooleanField("Eighth Period Waitlist Notifications", default=True) + glance_notifications = models.BooleanField("Eighth Period Glance Notification", default=True) + announcement_notifications = models.BooleanField("Announcement Notifications", default=True) + poll_notifications = models.BooleanField("Poll Notifications", default=True) + bus_notifications = models.BooleanField("Bus Notifications", default=True) + + # True if the user is subscribed to at least one device or more + is_subscribed = models.BooleanField(default=False) + + def __str__(self): + return str(self.user) + + +class WebPushNotification(models.Model): + """This model is only used to store sent WebPushNotifications. + If you are trying to send a notification, using the send notification + functions located in intranet.apps.notifications.tasks + Notifications sent from those functions are automatically added here + to keep track of sent notifications' history + """ + + class Targets(models.TextChoices): + USER = "user", "User" + DEVICE = "device", "Single Device" + DEVICE_QUERYSET = "device_queryset", "Device Queryset (Multiple Devices)" + + date_sent = models.DateTimeField(auto_now=True) + target = models.CharField(max_length=15, choices=Targets.choices) + + user_sent = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications_user_sent") + device_queryset_sent = models.ManyToManyField(WebPushDevice, blank=True) + device_sent = models.ForeignKey(WebPushDevice, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications_device_sent") + + title = models.TextField() + body = models.TextField() diff --git a/intranet/apps/notifications/serializers.py b/intranet/apps/notifications/serializers.py new file mode 100644 index 00000000000..b98eca0dcf1 --- /dev/null +++ b/intranet/apps/notifications/serializers.py @@ -0,0 +1,9 @@ +from push_notifications.models import WebPushDevice +from rest_framework import serializers + + +class WebPushDeviceSerializer(serializers.ModelSerializer): + class Meta: + model = WebPushDevice + fields = ["registration_id", "p256dh", "auth", "user"] + read_only_fields = ["user"] diff --git a/intranet/apps/notifications/tasks.py b/intranet/apps/notifications/tasks.py index 19edf424e33..d49361d36e5 100644 --- a/intranet/apps/notifications/tasks.py +++ b/intranet/apps/notifications/tasks.py @@ -1,9 +1,16 @@ import functools +import json +from typing import Dict from celery import shared_task from celery.utils.log import get_task_logger +from django.db.models import QuerySet +from django.templatetags.static import static +from push_notifications.models import WebPushDevice +from ... import settings from . import emails +from .models import WebPushNotification logger = get_task_logger(__name__) @@ -15,3 +22,114 @@ def email_send_task(*args, **kwargs): kwargs["custom_logger"] = logger return emails.email_send(*args, **kwargs) + + +# Can't wrap the notification functions into a much cleaner class because celery doesn't support it :( + + +@shared_task +def send_notification_to_device( + device: WebPushDevice, + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + try: + device.send_message(dumped_json) + except Exception as e: # Lots of things can go wrong with individual device subscriptions + logger.error("An error occurred while trying to send a webpush notification: %s", e) # pylint: disable=broad-except + + WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.DEVICE, + device_sent=device, + ) + + +@shared_task +def send_notification_to_user( + user, + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + for device in WebPushDevice.objects.filter(user=user): + try: + device.send_message(dumped_json) + except Exception as e: + logger.error("An error occurred while trying to send a webpush notification: %s", e) # pylint: disable=broad-except + + WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.USER, + user_sent=user, + ) + + +@shared_task +def send_bulk_notification( + filtered_objects: QuerySet[WebPushDevice], + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + for device in filtered_objects: + try: + device.send_message(dumped_json) + except Exception as e: + logger.error("An error occurred while trying to send a webpush notification: %s", e) # pylint: disable=broad-except + + obj = WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.DEVICE_QUERYSET, + ) + + obj.device_queryset_sent.set(filtered_objects) + + +@shared_task +def remove_inactive_subscriptions(): + inactive_subscriptions = WebPushDevice.objects.filter(active=False) + inactive_subscriptions.delete() diff --git a/intranet/apps/notifications/tests.py b/intranet/apps/notifications/tests.py new file mode 100644 index 00000000000..482a0182226 --- /dev/null +++ b/intranet/apps/notifications/tests.py @@ -0,0 +1,243 @@ +# pylint: disable=no-member,unused-argument + +from unittest import mock +from unittest.mock import ANY + +from django.urls import reverse +from push_notifications.models import WebPushDevice +from rest_framework.response import Response + +from intranet.apps.notifications.models import WebPushNotification +from intranet.apps.notifications.tasks import send_bulk_notification, send_notification_to_device, send_notification_to_user +from intranet.test.ion_test import IonTestCase + + +class NotificationsWebpushTest(IonTestCase): + """Tests for the notifications/webpush module, including api""" + + def setUp(self): + self.endpoint = "push.api.example.com/example/endpoint/id" + self.mock_device = mock.Mock() + self.mock_device.registration_id = self.endpoint + self.mock_device.auth = "authtest" + self.mock_device.p256dh = "p256dhtest" + self.user = self.login() + + def create_webpush_device(self, user, registration_id): + return WebPushDevice.objects.create( + registration_id=registration_id, + p256dh=self.mock_device.p256dh, + auth=self.mock_device.auth, + user=user, + ) + + @mock.patch("intranet.apps.notifications.api.GetApplicationServerKey.get") + def test_get_app_server_key(self, mock_view): + mock_view.return_value = Response({"applicationServerKey": "mock-key"}, status=200) + + response = self.client.get(reverse("api_get_vapid_application_server_key")) + + self.assertEqual(response.status_code, 200) + self.assertIn("applicationServerKey", response.json()) + + def test_webpush_subscription(self): + response = self.client.post( + reverse("api_webpush_subscribe"), + format="json", + data={ + "registration_id": self.mock_device.registration_id, + "p256dh": self.mock_device.p256dh, + "auth": self.mock_device.auth, + }, + ) + + self.assertEqual(response.status_code, 201) + + self.assertEqual(WebPushDevice.objects.count(), 1) + obj = WebPushDevice.objects.get(registration_id=self.mock_device.registration_id) + self.assertEqual(obj.user, self.user) + + def test_webpush_unsubscribe(self): + self.create_webpush_device(self.user, self.mock_device.registration_id) + + self.assertEqual(WebPushDevice.objects.count(), 1) + + response = self.client.post( + reverse("api_webpush_unsubscribe"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + + self.assertEqual(WebPushDevice.objects.count(), 0) + + def test_webpush_update_subscription(self): + self.create_webpush_device(self.user, self.mock_device.registration_id) + + new_registration_id = "push.api.example.com/new/unique/id" + new_p256dh = "p256dhalt" + new_auth = "authalt" + + response = self.client.post( + reverse("api_webpush_update_subscription"), + format="json", + data={ + "old_registration_id": self.mock_device.registration_id, + "registration_id": new_registration_id, + "p256dh": new_p256dh, + "auth": new_auth, + }, + ) + + self.assertEqual(response.status_code, 200) + + device = WebPushDevice.objects.filter(user=self.user).first() + + self.assertEqual(device.registration_id, new_registration_id) + self.assertEqual(device.p256dh, new_p256dh) + self.assertEqual(device.auth, new_auth) + + def test_webpush_subscription_status(self): + response = self.client.post( + reverse("api_webpush_subscription_status"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], False) + + response = self.client.post( + reverse("api_webpush_subscribe"), + format="json", + data={ + "registration_id": self.mock_device.registration_id, + "p256dh": self.mock_device.p256dh, + "auth": self.mock_device.auth, + }, + ) + + self.assertEqual(response.status_code, 201) + + response = self.client.post( + reverse("api_webpush_subscription_status"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], True) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_user_message(self, webpush_device_mock): + device = self.create_webpush_device(self.user, self.mock_device.registration_id) + + title = "example" + body = "notification" + url = "example.com" + + send_notification_to_user(user=self.user, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(device, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.USER) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_device_message(self, webpush_device_mock): + device = self.create_webpush_device(self.user, self.mock_device.registration_id) + + title = "example" + body = "notification" + url = "example.com" + + device = WebPushDevice.objects.filter(user=self.user).first() + + send_notification_to_device(device=device, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(device, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.DEVICE) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_bulk_message(self, webpush_device_mock): + self.create_webpush_device(self.user, self.mock_device.registration_id) + self.create_webpush_device(self.user, "push.api.example.com/unique/id") + + title = "example" + body = "notification" + url = "example.com" + + filtered_objects = WebPushDevice.objects.filter(user=self.user) + + send_bulk_notification(filtered_objects=filtered_objects, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(ANY, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.DEVICE_QUERYSET) + + def test_webpush_notif_list_view(self): + self.user = self.make_admin() + response = self.client.get(reverse("notif_webpush_list")) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_list.html") + + def test_webpush_notif_device_info_view(self): + self.user = self.make_admin() + device = self.create_webpush_device(user=self.user, registration_id=self.mock_device.registration_id) + + WebPushNotification.objects.create( + title="example", + body="description", + target=WebPushNotification.Targets.DEVICE, + device_sent=device, + ) + + response = self.client.get(reverse("notif_webpush_device_view", kwargs={"model_id": WebPushNotification.objects.all().first().id})) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_device_info.html") + + @mock.patch("intranet.apps.notifications.tasks.send_bulk_notification.delay", autospec=True) + def test_webpush_post_view(self, send_mock): + self.user = self.make_admin() + self.create_webpush_device(self.user, self.mock_device.registration_id) + response = self.client.get(reverse("notif_webpush_post_view")) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_post.html") + + title = "example" + body = "notification" + url = "https://www.example.com" + + response = self.client.post( + reverse("notif_webpush_post_view"), + { + "title": title, + "body": body, + "url": url, + }, + ) + + # We can't assert if send_mock was called with specific argument values... + # because mock doesn't support comparing Django objects. + # Instead, it changes the id of the object when mocking, meaning they can't be compared + + send_mock.assert_called_once_with(title=ANY, body=ANY, data=ANY, filtered_objects=ANY) diff --git a/intranet/apps/notifications/urls.py b/intranet/apps/notifications/urls.py index 8fb3cb99808..b8c0dab941a 100644 --- a/intranet/apps/notifications/urls.py +++ b/intranet/apps/notifications/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path +from django.views.generic import TemplateView from . import views @@ -8,4 +9,17 @@ re_path(r"^/chrome/getdata$", views.chrome_getdata_view, name="notif_chrome_getdata"), re_path(r"^/gcm/post$", views.gcm_post_view, name="notif_gcm_post"), re_path(r"^/gcm/list$", views.gcm_list_view, name="notif_gcm_list"), + re_path(r"^/webpush/list$", views.webpush_list_view, name="notif_webpush_list"), + re_path(r"^/webpush/list/(?P\d+)$", views.webpush_device_info_view, name="notif_webpush_device_view"), + re_path( + r"^/webpush/ios/setup$", + TemplateView.as_view(template_name="notifications/ios_notifications_guide.html", content_type="text/html"), + name="ios_notif_setup", + ), + re_path(r"^/webpush/post$", views.webpush_post_view, name="notif_webpush_post_view"), + re_path( + r"^/webpush/manage$", + TemplateView.as_view(template_name="notifications/manage.html", content_type="text/html"), + name="manage_push_notifs", + ), ] diff --git a/intranet/apps/notifications/utils.py b/intranet/apps/notifications/utils.py new file mode 100644 index 00000000000..1a9edfaf1d3 --- /dev/null +++ b/intranet/apps/notifications/utils.py @@ -0,0 +1,10 @@ +def truncate_content(content: str) -> str: + if len(content) > 200: + return content[:200] + "..." + return content + + +def truncate_title(title: str) -> str: + if len(title) > 50: + return title[:50] + "..." + return title diff --git a/intranet/apps/notifications/views.py b/intranet/apps/notifications/views.py index 0e8c6da9cae..ad986247903 100644 --- a/intranet/apps/notifications/views.py +++ b/intranet/apps/notifications/views.py @@ -1,16 +1,23 @@ import json import logging +import os import requests from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.http import HttpResponse +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import FileResponse, HttpResponse from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt +from push_notifications.models import WebPushDevice from ..schedule.notifications import chrome_getdata_check -from .models import GCMNotification, NotificationConfig +from ..users.models import User +from .forms import SendPushNotificationForm +from .models import GCMNotification, NotificationConfig, WebPushNotification +from .tasks import send_bulk_notification logger = logging.getLogger(__name__) @@ -200,3 +207,104 @@ def get_gcm_schedule_uids(): nc_all = NotificationConfig.objects.exclude(gcm_token=None).exclude(gcm_optout=True) nc = nc_all.filter(user__receive_schedule_notifications=True) return nc.values_list("id", flat=True) + + +@login_required +def webpush_list_view(request): + if not request.user.has_admin_permission("notifications"): + return redirect("index") + notifications = WebPushNotification.objects.all().order_by("-date_sent") + + paginator = Paginator(notifications, 20) + + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render( + request, + "notifications/webpush_list.html", + { + "notifications": notifications, + "page_obj": page_obj, + "targets": WebPushNotification.Targets, + "paginator": paginator, + }, + ) + + +@login_required +def webpush_device_info_view(request, model_id=None): + if not request.user.has_admin_permission("notifications"): + return redirect("index") + notifications = WebPushNotification.objects.filter(id=model_id).first() + notification_target = notifications.target + + if notifications is not None: + if notification_target == WebPushNotification.Targets.DEVICE: + notifications = notifications.device_sent + elif notification_target == WebPushNotification.Targets.DEVICE_QUERYSET: + notifications = notifications.device_queryset_sent.all() + else: + messages.error(request, "The notification type cannot be found or is 'Targets.USER'") + return redirect("index") + else: + messages.error(request, f"Can't find notification with id {model_id}") + return redirect("index") + + if notification_target == WebPushNotification.Targets.DEVICE_QUERYSET: + paginator = Paginator(notifications, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + else: + page_obj = None + paginator = None + + return render( + request, + "notifications/webpush_device_info.html", + { + "notifications": notifications, + "page_obj": page_obj, + "paginator": paginator, + }, + ) + + +@login_required() +def webpush_post_view(request): + if not request.user.has_admin_permission("notifications"): + return redirect("index") + + if request.method == "POST": + form = SendPushNotificationForm(data=request.POST) + + if form.is_valid(): + if not form.cleaned_data["users"].exists() and not form.cleaned_data["groups"].exists(): + devices = WebPushDevice.objects.all() + else: + group_users = User.objects.filter(groups__in=form.cleaned_data["groups"]) + + devices = WebPushDevice.objects.filter(Q(user__in=form.cleaned_data["users"]) | Q(user__in=group_users)) + + send_bulk_notification.delay( + title=form.cleaned_data["title"], body=form.cleaned_data["body"], data={"url": form.cleaned_data["url"]}, filtered_objects=devices + ) + + messages.success(request, "Sent post notification.") + else: + messages.error(request, "Form invalid.") + + send_push_notification_form = SendPushNotificationForm() + + return render( + request, + "notifications/webpush_post.html", + { + "form": send_push_notification_form, + }, + ) + + +def serve_serviceworker(request): + file_path = os.path.join(settings.STATIC_ROOT, "js", "serviceworker.js") + return FileResponse(open(file_path, "rb"), content_type="application/javascript") diff --git a/intranet/apps/polls/forms.py b/intranet/apps/polls/forms.py index f6a61172865..41469c73f98 100644 --- a/intranet/apps/polls/forms.py +++ b/intranet/apps/polls/forms.py @@ -5,6 +5,13 @@ class PollForm(forms.ModelForm): + send_notification = forms.BooleanField( + initial=True, + required=False, + help_text="This will send a notification to eligible students asking them to vote in this poll", + label="Send notification", + ) + def clean_description(self): desc = self.cleaned_data["description"] # SAFE HTML @@ -20,3 +27,6 @@ class Meta: "is_secret": "This will prevent Ion administrators from viewing individual users' votes.", "is_election": "Enable election formatting and results features.", } + + # We need to make sure the send_notification field doesn't look out of place on the form + field_order = Meta.fields[:4] + ["send_notification"] + Meta.fields[4:] diff --git a/intranet/apps/polls/notifications.py b/intranet/apps/polls/notifications.py new file mode 100644 index 00000000000..efa45a35b37 --- /dev/null +++ b/intranet/apps/polls/notifications.py @@ -0,0 +1,34 @@ +from django.db.models import Q +from django.urls import reverse +from django.utils.html import strip_tags +from push_notifications.models import WebPushDevice + +from intranet import settings +from intranet.apps.notifications.tasks import send_bulk_notification +from intranet.apps.notifications.utils import truncate_content, truncate_title +from intranet.apps.polls.models import Poll +from intranet.apps.users.models import User + + +def send_poll_notification(obj: Poll) -> None: + """Send a (Web)push notification asking all users who can see the poll to vote + + obj: The poll object + + """ + + if not obj.groups.all(): + users = User.objects.filter(push_notification_preferences__poll_notifications=True) + devices = WebPushDevice.objects.filter(user__in=users) + else: + users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__poll_notifications=True)) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"New Poll: {truncate_title(obj.title)}", + body=truncate_content(strip_tags(obj.description)), + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("poll_vote", args=[obj.id]), + }, + ) diff --git a/intranet/apps/polls/views.py b/intranet/apps/polls/views.py index f9274628039..c2c397ab6cf 100644 --- a/intranet/apps/polls/views.py +++ b/intranet/apps/polls/views.py @@ -21,6 +21,7 @@ from ..auth.decorators import deny_restricted from .forms import PollForm from .models import Answer, Choice, Poll, Question +from .notifications import send_poll_notification logger = logging.getLogger(__name__) @@ -662,6 +663,9 @@ def add_poll_view(request): process_question_data(instance, question_data) + if request.POST.get("send_notification"): + send_poll_notification(instance) + messages.success(request, "The poll has been created.") return redirect("polls") else: diff --git a/intranet/apps/preferences/forms.py b/intranet/apps/preferences/forms.py index 10e027f7822..583f5b13fbc 100644 --- a/intranet/apps/preferences/forms.py +++ b/intranet/apps/preferences/forms.py @@ -3,7 +3,9 @@ from django import forms from django.contrib.auth import get_user_model +from ... import settings from ..bus.models import Route +from ..notifications.models import UserPushNotificationPreferences from ..users.models import Email, Grade, Phone, Website logger = logging.getLogger(__name__) @@ -131,6 +133,32 @@ class Meta: fields = ["url"] +class PushNotificationOptionsForm(forms.ModelForm): + class Meta: + model = UserPushNotificationPreferences + fields = [ + "eighth_reminder_notifications", + "eighth_waitlist_notifications", + "glance_notifications", + "announcement_notifications", + "poll_notifications", + "bus_notifications", + ] + + help_texts = { + "eighth_reminder_notifications": f"Receive reminder notifications to sign up for eighth period if you " + f"haven't signed up for one " + f"{settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} " + f"minutes prior to when blocks lock", + "eighth_waitlist_notifications": "Receive notifications when waitlisted for an activity. Must be enabled to use the waitlist feature", + "glance_notifications": "Receive your eighth period glance (a short message telling you which activities " + "you signed up for) as a notification when eighth period starts", + "announcement_notifications": "Receive notifications whenever an announcement is posted on Ion", + "poll_notifications": "Receive notifications whenever a poll you can vote in is available", + "bus_notifications": "Receive a notification at dismissal telling you your bus location and if it's delayed or not", + } + + PhoneFormset = forms.inlineformset_factory(get_user_model(), Phone, form=PhoneForm, extra=1) EmailFormset = forms.inlineformset_factory(get_user_model(), Email, form=EmailForm, extra=1) WebsiteFormset = forms.inlineformset_factory(get_user_model(), Website, form=WebsiteForm, extra=1) diff --git a/intranet/apps/preferences/views.py b/intranet/apps/preferences/views.py index 6bcbf78337c..3c10fffda44 100644 --- a/intranet/apps/preferences/views.py +++ b/intranet/apps/preferences/views.py @@ -5,12 +5,21 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect, render +from django.shortcuts import redirect, render, reverse from ..auth.decorators import eighth_admin_required from ..bus.models import Route +from ..notifications.models import UserPushNotificationPreferences from ..users.models import Email -from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm +from .forms import ( + BusRouteForm, + DarkModeForm, + EmailFormset, + NotificationOptionsForm, + PreferredPictureForm, + PrivacyOptionsForm, + PushNotificationOptionsForm, +) # from .forms import (BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PhoneFormset, PreferredPictureForm, PrivacyOptionsForm, # WebsiteFormset) @@ -18,7 +27,6 @@ logger = logging.getLogger(__name__) - """ NOTE: Phone and website information have been disabled because of privacy reasons. """ @@ -197,6 +205,17 @@ def get_notification_options(user): return notification_options +def get_push_notifications_options(user): + return { + "eighth_reminder_notifications": user.push_notification_preferences.eighth_reminder_notifications, + "eighth_waitlist_notifications": user.push_notification_preferences.eighth_waitlist_notifications, + "glance_notifications": user.push_notification_preferences.glance_notifications, + "announcement_notifications": user.push_notification_preferences.announcement_notifications, + "poll_notifications": user.push_notification_preferences.poll_notifications, + "bus_notifications": user.push_notification_preferences.bus_notifications, + } + + def save_notification_options(request, user): notification_options = get_notification_options(user) notification_options_form = NotificationOptionsForm(user, data=request.POST, initial=notification_options) @@ -219,6 +238,17 @@ def save_notification_options(request, user): return notification_options_form +def save_push_notifications_options(request, user): + push_notifications_options = get_push_notifications_options(user) + obj, _ = UserPushNotificationPreferences.objects.get_or_create(user=user) + + push_notifications_options_form = PushNotificationOptionsForm(data=request.POST, initial=push_notifications_options, instance=obj) + if push_notifications_options_form.is_valid() and push_notifications_options_form.has_changed(): + push_notifications_options_form.save() + + return push_notifications_options_form + + def get_bus_route(user): """Get a user's bus route to pass as an initial value to a BusRouteForm.""" @@ -293,38 +323,46 @@ def save_dark_mode_settings(request, user): @login_required def preferences_view(request): """View and process updates to the preferences page.""" + # pylint: disable=E0606 + user = request.user if request.method == "POST": - logger.debug("Preparing to update user preferences for user %s", request.user.id) - # phone_formset, email_formset, website_formset, errors = save_personal_info(request, user) - _, email_formset, _, errors = save_personal_info(request, user) - if user.is_student: - preferred_pic_form = save_preferred_pic(request, user) - bus_route_form = save_bus_route(request, user) - """ - The privacy options form is disabled due to the - permissions feature being unused and changes to school policy. - """ - # privacy_options_form = save_privacy_options(request, user) - privacy_options_form = None - else: - preferred_pic_form = None - bus_route_form = None - privacy_options_form = None - notification_options_form = save_notification_options(request, user) + if request.POST.get("updatepushprefs", "").lower() == "": + logger.debug("Preparing to update user preferences for user %s", request.user.id) + # phone_formset, email_formset, website_formset, errors = save_personal_info(request, user) + _, email_formset, _, errors = save_personal_info(request, user) + if user.is_student: + preferred_pic_form = save_preferred_pic(request, user) + bus_route_form = save_bus_route(request, user) + """ + The privacy options form is disabled due to the + permissions feature being unused and changes to school policy. + """ + # privacy_options_form = save_privacy_options(request, user) + privacy_options_form = None + else: + preferred_pic_form = None + bus_route_form = None + privacy_options_form = None + notification_options_form = save_notification_options(request, user) + + dark_mode_form = save_dark_mode_settings(request, user) - dark_mode_form = save_dark_mode_settings(request, user) + for error in errors: + messages.error(request, error) - for error in errors: - messages.error(request, error) + try: + save_gcm_options(request, user) + except AttributeError: + pass - try: - save_gcm_options(request, user) - except AttributeError: - pass + return redirect("preferences") - return redirect("preferences") + elif request.POST.get("updatepushprefs").lower() == "true": + push_notifications_options_form = save_push_notifications_options(request, user) + messages.success(request, "Push notification settings updated.") + return redirect(f"{reverse('preferences')}?pushprefs=true") else: # phone_formset = PhoneFormset(instance=user, prefix="pf") @@ -354,9 +392,23 @@ def preferences_view(request): notification_options = get_notification_options(user) notification_options_form = NotificationOptionsForm(user, initial=notification_options) + push_notifications_options = get_push_notifications_options(user) + push_notifications_options_form = PushNotificationOptionsForm(initial=push_notifications_options) dark_mode_form = DarkModeForm(user, initial={"dark_mode_enabled": user.dark_mode_properties.dark_mode_enabled}) + enable_get_params = request.COOKIES.get("enableGetParams", "false") + + if request.method == "GET" and enable_get_params == "true": + if request.GET.get("success", "") != "": + messages.success(request, f"Success: {request.GET.get('success')}") + elif request.GET.get("error", "") != "": # Success messages take precedence + messages.error(request, f"An error occurred: {request.GET.get('error')}") + + user_agent = request.user_agent + is_ios = user_agent.is_mobile and user_agent.os.family == "iOS" + open_push_notifs_prefs = request.GET.get("pushprefs") if enable_get_params == "true" else "false" + context = { # "phone_formset": phone_formset, "email_formset": email_formset, @@ -366,6 +418,10 @@ def preferences_view(request): "notification_options_form": notification_options_form, "bus_route_form": bus_route_form if settings.ENABLE_BUS_APP else None, "dark_mode_form": dark_mode_form, + "open_push_notif_prefs": open_push_notifs_prefs, + "push_notifications_options_form": push_notifications_options_form, + "is_ios": is_ios, + "ENABLE_WAITLIST": settings.ENABLE_WAITLIST, } return render(request, "preferences/preferences.html", context) diff --git a/intranet/apps/templatetags/forms.py b/intranet/apps/templatetags/forms.py index 56efe5f9f22..224b6e79929 100644 --- a/intranet/apps/templatetags/forms.py +++ b/intranet/apps/templatetags/forms.py @@ -32,3 +32,8 @@ def field_array_size(field): if re.match(rf"^{prefix}_(\d+)$", field_name): count += 1 return count + + +@register.filter +def field_type(field): + return field.field.widget.__class__.__name__ diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py index d592100c261..123bfcc1a83 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -21,6 +21,7 @@ from ..bus.models import Route from ..eighth.models import EighthBlock, EighthSignup, EighthSponsor from ..groups.models import Group +from ..notifications.models import UserPushNotificationPreferences from ..polls.models import Poll from ..preferences.fields import PhoneField @@ -741,6 +742,17 @@ def is_board_admin(self) -> bool: return self.has_admin_permission("board") + @property + def is_notifications_admin(self) -> bool: + """Checks if user is a notifications admin. + + Returns: + Whether this user is a notifications admin. + + """ + + return self.has_admin_permission("notifications") + @property def is_global_admin(self) -> bool: """Checks if user is a global admin. @@ -1078,6 +1090,8 @@ def __getattr__(self, name): return UserProperties.objects.get_or_create(user=self)[0] elif name == "dark_mode_properties": return UserDarkModeProperties.objects.get_or_create(user=self)[0] + elif name == "push_notification_preferences": + return UserPushNotificationPreferences.objects.get_or_create(user=self)[0] raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") def __str__(self): diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py index c073348baaa..297254c32b0 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -1,3 +1,5 @@ +# pylint: disable=C0302 + import datetime import logging import os @@ -103,6 +105,8 @@ # App and functionality availability toggles ENABLE_WAITLIST = False # Eighth waitlist. WARNING: Enabling the waitlist causes severe performance issues +ENABLE_WEBPUSH = True # Enable webpush notifications + ENABLE_BUS_APP = True ENABLE_BUS_DRIVER = True @@ -303,6 +307,34 @@ os.path.join(PROJECT_ROOT, "static") ] +# Settings for Webpush (used in the django-push-notifications library) +PUSH_NOTIFICATIONS_SETTINGS = { + "WP_PRIVATE_KEY": os.path.join(os.path.dirname(PROJECT_ROOT), "keys", "webpush", "private_key.pem"), + "WP_CLAIMS": {"sub": "mailto:intranet@tjhsst.edu"}, +} + +# Used in instances where the request object is not available, so we can't build an absolute URI +PUSH_NOTIFICATIONS_BASE_URL = "https://ion.tjhsst.edu" if PRODUCTION else "http://localhost:8080" + +# How many minutes before an eighth period locks should notifications be sent out +PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES = 40 + +# Determines the name/location of each bus spot when sending Webpush notifications +# Keys are lowercase so they don't look out of place when formatted into the notification body +# The values are bus spot ID's. I.e. "_9" is written as 9 + +PUSH_ROUTE_TRANSLATIONS = { + "curb, left section": {7, 8, 9}, + "curb, middle section": {3, 4, 5, 6}, + "curb, right section": {1, 2, 41}, + "front row, left section": {19, 20, 21, 22}, + "back row, left section": {42, 43, 44, 45}, + "front row, middle section": {14, 15, 16, 17, 18}, + "back row, middle section": {27, 28, 29, 30}, + "front row, right section": {10, 11, 12, 13}, + "back row, right section": {23, 24, 25, 26}, +} + # List of finder classes that know how to find static files in # various locations. STATICFILES_FINDERS = [ @@ -687,6 +719,7 @@ def get_month_seconds(): "simple_history", # django-simple-history "django_referrer_policy", "django_user_agents", + "push_notifications", # django-push-notifications ] # Django Channels Configuration (we use this for websockets) @@ -934,6 +967,26 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th "schedule": celery.schedules.crontab(day_of_month=3, hour=1), "args": (), }, + "push-eighth-reminder-notifications": { + "task": "intranet.apps.eighth.tasks.push_eighth_reminder_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=0), + "args": [True], + }, + "push-glance-notifications": { + "task": "intranet.apps.eighth.tasks.push_glance_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=0), + "args": [True], + }, + "push-bus-notifications": { + "task": "intranet.apps.bus.tasks.push_bus_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=0), + "args": [True], + }, + "remove-inactive-subscriptions": { + "task": "intranet.apps.notifications.tasks.remove_inactive_subscriptions", + "schedule": celery.schedules.crontab(day_of_week=0, hour=0, minute=0), + "args": (), + }, } MAINTENANCE_MODE = False diff --git a/intranet/static/css/dark/preferences.scss b/intranet/static/css/dark/preferences.scss index f9f72d02142..775926f519b 100644 --- a/intranet/static/css/dark/preferences.scss +++ b/intranet/static/css/dark/preferences.scss @@ -3,3 +3,39 @@ select { color: white; } +.modal { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: rgb(30, 30, 30); + margin: 15% auto; + padding: 20px; + border: 1px solid #232323; + width: 80%; + max-width: 500px; + border-radius: 5px; +} + +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} diff --git a/intranet/static/css/preferences.scss b/intranet/static/css/preferences.scss index 6150b0afebe..834e1aa7e69 100644 --- a/intranet/static/css/preferences.scss +++ b/intranet/static/css/preferences.scss @@ -70,3 +70,40 @@ tr:nth-last-child(2) a.delete-row { z-index: 1; margin-bottom: 15px; } + +.modal { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: rgba(254, 254, 254, 0.65); + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: 500px; + border-radius: 5px; +} + +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} diff --git a/intranet/static/img/guides/add_to_home_screen_ios.png b/intranet/static/img/guides/add_to_home_screen_ios.png new file mode 100644 index 00000000000..23bed14f1c6 Binary files /dev/null and b/intranet/static/img/guides/add_to_home_screen_ios.png differ diff --git a/intranet/static/js/serviceworker.js b/intranet/static/js/serviceworker.js new file mode 100644 index 00000000000..b9b2b1acc5c --- /dev/null +++ b/intranet/static/js/serviceworker.js @@ -0,0 +1,53 @@ +self.addEventListener("push", function(event) { + const data = event.data.json(); + const options = { + body: data.body, + icon: data.icon, + badge: data.badge, + data: { + url: data.data.url + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Immediately replace any old service worker(s) +self.addEventListener("install", function (event) { + self.skipWaiting(); +}); + +self.addEventListener("notificationclick", function(event) { + event.notification.close(); + event.waitUntil( + // eslint-disable-next-line no-undef + clients.openWindow(event.notification.data.url) + ); +}); + +// Update subscription details on server on expiration + +self.addEventListener("pushsubscriptionchange", function(event) { + event.waitUntil( + fetch("/api/notifications/webpush/update_subscription", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + "old_registration_id": event.oldSubscription.endpoint, + "registration_id": event.newSubscription.endpoint, + "p256dh": btoa( + String.fromCharCode.apply( + null, new Uint8Array(event.newSubscription.getKey("p256dh")) + ) + ), + "auth": btoa( + String.fromCharCode.apply( + null, new Uint8Array(event.newSubscription.getKey("auth")) + ) + ), + }) + }) + ); +}); diff --git a/intranet/static/serviceworker.js b/intranet/static/serviceworker.js deleted file mode 100644 index 8feb77e98e2..00000000000 --- a/intranet/static/serviceworker.js +++ /dev/null @@ -1,81 +0,0 @@ -var self = this; - -self.addEventListener('push', function(event) { - console.log('Received a push message', event); - - var data = {}; - if (event.data) { - data = event.data.json(); - console.debug(data); - showNotif(event, data) - } else { - var evt = event; - console.debug("Fetching data text...") - fetch("/notifications/chrome/getdata", { - credentials: 'include' - }).then(function(r) { - console.debug(r); - return r.json(); - }).then(function(j) { - console.debug(j); - if (j == null) return; - showNotif(evt, j); - }); - } - - - showNotif = function(event, data) { - //replace with message data fetched from Ion API/DB based on logged in user's UID - var title = data.title || "Intranet Notification"; - var body = data.text || "Click here to view." - var icon = '/static/img/logos/touch/touch-icon192.png'; - var tag = data.url ? "url=" + data.url : (data.tag || 'ion-notification'); - - self.registration.showNotification(title, { - body: body, - icon: icon, - tag: tag - }); - } - -}); - -self.addEventListener('notificationclick', function(event) { - var tag = event.notification.tag; - console.log('Notification click: ', tag); - - event.notification.close(); - - tagUrl = '/?src=sw'; - var tags = tag.split("="); - if (tags[0] == "url") { - tagUrl = "/" + tags[1]; - if (tagUrl.indexOf("?") == -1) { - tagUrl += "?src=sw"; - } else { - tagUrl += "&src=sw"; - } - if (tagUrl.substring(0, 2) == "//") { - tagUrl = "/" + tagUrl.substring(2); - } - } - - console.log("tagUrl: ", tagUrl); - - event.waitUntil( - clients.matchAll({ - type: 'window' - }) - .then(function(clientList) { - for (var i = 0; i < clientList.length; i++) { - var client = clientList[i]; - if (client.url === tagUrl && 'focus' in client) { - return client.focus(); - } - } - if (clients.openWindow) { - return clients.openWindow(tagUrl); - } - }) - ); -}) \ No newline at end of file diff --git a/intranet/templates/dashboard/admin.html b/intranet/templates/dashboard/admin.html index db2b9ec8f6b..57afb2b0461 100644 --- a/intranet/templates/dashboard/admin.html +++ b/intranet/templates/dashboard/admin.html @@ -23,6 +23,9 @@

OAuth Applications
Print Jobs
{% endif %} + {% if request.user.is_notifications_admin %} + Manage Push Notifications
+ {% endif %} {% if request.user.is_eighth_admin %} diff --git a/intranet/templates/notifications/ios_notifications_guide.html b/intranet/templates/notifications/ios_notifications_guide.html new file mode 100644 index 00000000000..d02e27a807c --- /dev/null +++ b/intranet/templates/notifications/ios_notifications_guide.html @@ -0,0 +1,51 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} + +{% block title %} + {{ block.super }} - Enable Notifications Guide +{% endblock %} + +{% block css %} + {{ block.super }} + +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Add Ion to your home screen (iOS)

+

Note: See + Apple's instructions + for the latest information

+
    +
  1. While viewing this website, tap the + + + + + + + + button in the menu bar.
  2. +
  3. Scroll down the list of options, and click 'Add to Home Screen'.
  4. +
  5. If you're trying to enable Push Notifications, open Ion from your home screen and subscribe via the preferences page.
  6. +
+

If you don't see the 'Add to Home Screen' option, click on Edit Actions in the same menu, and then click the '+' symbol next to the option

+ Add to Home Screen Visual Image +
+{% endblock %} diff --git a/intranet/templates/notifications/manage.html b/intranet/templates/notifications/manage.html new file mode 100644 index 00000000000..e4c781e589b --- /dev/null +++ b/intranet/templates/notifications/manage.html @@ -0,0 +1,32 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} + +{% block title %} + {{ block.super }} - Manage Push Notifications +{% endblock %} + +{% block css %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Manage Push Notifications

+ +
+{% endblock %} diff --git a/intranet/templates/notifications/webpush_device_info.html b/intranet/templates/notifications/webpush_device_info.html new file mode 100644 index 00000000000..00f55cb8883 --- /dev/null +++ b/intranet/templates/notifications/webpush_device_info.html @@ -0,0 +1,83 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} + +{% block title %} + {{ block.super }} - WebPush Device List +{% endblock %} + +{% block css %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Devices Sent

+ + + + + + + + + {% if page_obj is not None %} + {% for item in page_obj.object_list %} + + + + + + {% endfor %} + {% else %} + + + + + + {% endif %} +
Device IDRegistration ID (Endpoint)Associated User
{{ item.id }} + {{ item.registration_id }} + + {{ item.user }} +
{{ notifications.id }} + {{ notifications.registration_id }} + + {{ notifications.user }} +
+ {% if page_obj is not None %} +
+
+ of {{ page_obj.num_pages }} + +
+ {% if page_obj.has_previous %} + + + + {% endif %} + {% if page_obj.has_next %} + + + + {% endif %} +
+ Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} items +
+
+ {% endif %} +
+{% endblock %} diff --git a/intranet/templates/notifications/webpush_list.html b/intranet/templates/notifications/webpush_list.html new file mode 100644 index 00000000000..27740a78278 --- /dev/null +++ b/intranet/templates/notifications/webpush_list.html @@ -0,0 +1,81 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} + +{% block title %} + {{ block.super }} - WebPush Notifications List +{% endblock %} + +{% block css %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Sent Notifications

+ + + + + + + + + + + + {% for item in page_obj.object_list %} + + + + + + + + + {% endfor %} +
IDTimeTypeTargetTitleBody
{{ item.id }}{{ item.date_sent }}{{ item.target }} + {% if item.target == targets.USER %} + {{ item.user_sent }} + {% elif item.target == targets.DEVICE %} + {{ item.device_sent }} + {% elif item.target == targets.DEVICE_QUERYSET %} + View Queryset ({{ item.device_queryset_sent.count }} items) + {% else %} + Unavailable + {% endif %} + {{ item.title }}{{ item.body|truncatechars:50 }}
+
+
+ of {{ paginator.num_pages }} + + {% if page_obj.has_previous %} + + + + {% endif %} + {% if page_obj.has_next %} + + + + {% endif %} +
+ Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} items +
+
+
+
+{% endblock %} diff --git a/intranet/templates/notifications/webpush_post.html b/intranet/templates/notifications/webpush_post.html new file mode 100644 index 00000000000..ca620c0a5f2 --- /dev/null +++ b/intranet/templates/notifications/webpush_post.html @@ -0,0 +1,45 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} + +{% block title %} + {{ block.super }} - WebPush Post Notification +{% endblock %} + +{% block css %} + {{ block.super }} + +{% endblock %} + +{% block js %} + {{ block.super }} + + +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Post Push Notification

+
+ {% csrf_token %} + + {{ form.as_table }} +
+ +
+
+{% endblock %} diff --git a/intranet/templates/preferences/preferences.html b/intranet/templates/preferences/preferences.html index a5e236c0bff..64004fb9639 100644 --- a/intranet/templates/preferences/preferences.html +++ b/intranet/templates/preferences/preferences.html @@ -21,44 +21,287 @@ + {% endblock %} @@ -92,39 +335,21 @@

Bus Route (PM)


Notification Options

Change how you receive notifications from Intranet.

- - {% if request.user.notificationconfig and request.user.notificationconfig.gcm_token %} - - - - - {% else %} - - - - - {% endif %} +
+ {% for field in notification_options_form %} -
- - - +
+ {% if field|field_type == "Select" %} + {{ field.label|add:":" }} {{ field }} + {% else %} + {{ field.errors }} + {{ field }} + {{ field.label }} + {% endif %} +
{% endfor %} -
- - - -
- - - -
- {{ field.errors }} - {{ field }} - - {{ field.label }} -
+
@@ -237,6 +462,31 @@

Dark Mode

+ diff --git a/intranet/urls.py b/intranet/urls.py index 4a1b4d21b6b..5671e7afe8c 100644 --- a/intranet/urls.py +++ b/intranet/urls.py @@ -4,6 +4,7 @@ from django.views.generic.base import RedirectView, TemplateView from intranet.apps.error.views import handle_404_view, handle_500_view, handle_503_view +from intranet.apps.notifications import views from intranet.apps.oauth.views import ApplicationDeleteView, ApplicationRegistrationView, ApplicationUpdateView admin.autodiscover() @@ -14,7 +15,6 @@ re_path(r"^favicon\.ico$", RedirectView.as_view(url="/static/img/favicon/favicon.ico"), name="favicon"), re_path(r"^robots\.txt$", RedirectView.as_view(url="/static/robots.txt"), name="robots"), re_path(r"^manifest\.json$", RedirectView.as_view(url="/static/manifest.json"), name="chrome_manifest"), - re_path(r"^serviceworker\.js$", RedirectView.as_view(url="/static/serviceworker.js"), name="chrome_serviceworker"), re_path(r"^api", include("intranet.apps.api.urls"), name="api_root"), re_path(r"^", include("intranet.apps.auth.urls")), re_path(r"^announcements", include("intranet.apps.announcements.urls")), @@ -66,6 +66,11 @@ TemplateView.as_view(template_name="docs/add-to-home-screen-android.html", content_type="text/html"), name="docs_add_to_home_screen_android", ), + re_path( + r"^serviceworker\.js$", + views.serve_serviceworker, + name="serve service worker" + ) ] if settings.SHOW_DEBUG_TOOLBAR: diff --git a/requirements.txt b/requirements.txt index 71bbe37d0a4..fb096883d72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,6 +59,9 @@ sphinx-bootstrap-theme==0.8.1 tblib==1.7.0 vine==5.0.0 xhtml2pdf==0.2.11 +django-push-notifications[WP]==3.1.0 +py-vapid==1.9.1 +pywebpush==2.0.0 # Not direct dependencies, but need to be bumped for some reason # (for example, bug or security fixes) @@ -66,3 +69,4 @@ asgiref>=3.3.4 pillow>=9.0.0 tinycss2 twisted>=21.7.0 +python-bidi==0.4.2 \ No newline at end of file