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..d7aecd1c07d 100755 --- a/config/docker/initial_setup.sh +++ b/config/docker/initial_setup.sh @@ -39,6 +39,9 @@ echo -e "${BLUE}${BOLD}Creating eighth period blocks...${CLEAR}" python3 create_blocks.py -l A B -c 60 python3 create_blocks.py -l A B C -i 4 -c 15 +echo -e "${BLUE}${BOLD}Generating vapid keys...${CLEAR}" +python3 create_vapid_keys.py + echo -e "${BLUE}${BOLD}Cleaning up scripts...${CLEAR}" for file in config/scripts/*.py; do rm "$(basename "$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.announcements.rst b/docs/sourcedoc/intranet.apps.announcements.rst index 1d90c545af2..0e083ac9564 100644 --- a/docs/sourcedoc/intranet.apps.announcements.rst +++ b/docs/sourcedoc/intranet.apps.announcements.rst @@ -76,6 +76,102 @@ intranet.apps.announcements.views module :undoc-members: :show-inheritance: +intranet.apps.announcements.views\_BACKUP\_1087 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BACKUP\_1114 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BACKUP\_1972 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1087 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1114 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1972 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1087 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1114 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1972 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1087 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1114 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1972 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1972 + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/sourcedoc/intranet.apps.bus.rst b/docs/sourcedoc/intranet.apps.bus.rst index 20a4b215591..fdcdf672677 100644 --- a/docs/sourcedoc/intranet.apps.bus.rst +++ b/docs/sourcedoc/intranet.apps.bus.rst @@ -76,6 +76,14 @@ intranet.apps.bus.urls module :undoc-members: :show-inheritance: +intranet.apps.bus.utils module +------------------------------ + +.. automodule:: intranet.apps.bus.utils + :members: + :undoc-members: + :show-inheritance: + intranet.apps.bus.views module ------------------------------ 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..ebbcb95fafa 100644 --- a/docs/sourcedoc/intranet.apps.notifications.rst +++ b/docs/sourcedoc/intranet.apps.notifications.rst @@ -4,6 +4,22 @@ intranet.apps.notifications package Submodules ---------- +intranet.apps.notifications.admin module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.admin + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.notifications.api module +-------------------------------------- + +.. automodule:: intranet.apps.notifications.api + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.emails module ----------------------------------------- @@ -12,6 +28,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 +44,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 +60,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 +76,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 43684b8b1fc..486a1771328 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -23,7 +23,12 @@ class Meta: fields = ["title", "author", "content", "groups", "expiration_date", "notify_post", "notify_email_all", "update_added_date", "pinned"] help_texts = { "expiration_date": "By default, announcements expire after two weeks. Choose the shortest time necessary.", - "notify_post": "If this box is checked, students who have signed up for notifications will receive an email.", + "notify_post": ( + "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." + ), "notify_email_all": ( "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." @@ -50,9 +55,12 @@ def __init__(self, user, *args, **kwargs): else: self.fields["activity"].queryset = [] self.fields["activity"].required = True - self.fields[ - "notify_post" - ].help_text = "If this box is checked, students who have subscribed to your club's announcements 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." + ) if "instance" in kwargs: # Don't allow changing the activity once the announcement has been created self.fields["activity"].widget.attrs["disabled"] = True @@ -92,7 +100,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. Choose the shortest time necessary." - 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 " @@ -153,7 +166,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 05a8da436bf..a019ff46a82 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__) @@ -148,7 +154,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")) @@ -216,3 +222,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 4412ac0299b..b2a1346c685 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -26,6 +26,7 @@ admin_request_announcement_email, announcement_approved_email, announcement_posted_email, + announcement_posted_push_notification, announcement_posted_twitter, request_announcement_email, ) @@ -62,6 +63,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/consumers.py b/intranet/apps/bus/consumers.py index fe8617f2711..e3bcf33f1fb 100644 --- a/intranet/apps/bus/consumers.py +++ b/intranet/apps/bus/consumers.py @@ -5,7 +5,9 @@ from django.conf import settings from django.utils import timezone +from ..schedule.models import Day from .models import BusAnnouncement, Route +from .tasks import push_bus_announcement_notifications, push_delayed_bus_notifications logger = logging.getLogger(__name__) @@ -43,12 +45,18 @@ def receive_json(self, content): # pylint: disable=arguments-differ content["announcement"] = "" announcement = BusAnnouncement.object() announcement.message = content["announcement"] + push_bus_announcement_notifications.delay(announcement.message) announcement.save() else: route = Route.objects.get(id=content["id"]) route.status = content["status"] if content["time"] == "afternoon" and route.status == "a": route.space = content["space"] + today = Day.objects.today() + + if today is not None and timezone.now() > today.end_datetime: + # Bus came late + push_delayed_bus_notifications.delay(route.bus_number) else: route.space = "" route.save() diff --git a/intranet/apps/bus/tasks.py b/intranet/apps/bus/tasks.py index 2dc575783b8..33b0f013098 100644 --- a/intranet/apps/bus/tasks.py +++ b/intranet/apps/bus/tasks.py @@ -1,7 +1,18 @@ +import datetime +from typing import Union + from celery import shared_task from celery.utils.log import get_task_logger +from django.db.models import Q +from django.urls import reverse +from push_notifications.models import WebPushDevice +from ... import settings +from ..notifications.tasks import send_bulk_notification, send_notification_to_user +from ..schedule.models import Day +from ..users.models import User from .models import Route +from .utils import extract_bus_number logger = get_task_logger(__name__) @@ -12,3 +23,109 @@ def reset_routes() -> None: for route in Route.objects.all(): route.reset_status() + + +@shared_task +def push_bus_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification to each user of their bus location + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + + if schedule: + day = Day.objects.today() + if day is not None: + if return_result: + return day.end_datetime + + push_bus_notifications.apply_async(eta=day.end_datetime) + logger.info("Push bus notifications scheduled at %s (bus info)", str(day.end_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.delay( + 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 + notif_sent = False + + if space is not None: + for key, value in route_translations.items(): + if space in value: + send_notification_to_user.delay( + 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"), + }, + ) + + notif_sent = True + if not notif_sent: + send_notification_to_user.delay( + user=user, + title="Bus not here", + body=f"Hmm, seems like your bus ({user.bus_route.bus_number}) isn't here yet. " + f"Check bus announcements for more information.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + return None + + +@shared_task +def push_delayed_bus_notifications(bus_number: str) -> None: + users = User.objects.filter(Q(push_notification_preferences__bus_notifications=True) & Q(bus_route__bus_number=bus_number)) + + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title="Bus Arrived", + body="Your delayed bus just arrived.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + +@shared_task +def push_bus_announcement_notifications(message: str) -> None: + bus_num = extract_bus_number(message) + if bus_num: + users = User.objects.filter(bus_route__route_name__contains=bus_num) + logger.error(bus_num) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"Bus Announcement (bus {bus_num})", + body=message, + 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/bus/utils.py b/intranet/apps/bus/utils.py new file mode 100644 index 00000000000..8377094f38c --- /dev/null +++ b/intranet/apps/bus/utils.py @@ -0,0 +1,24 @@ +import re +from typing import Union + + +def extract_bus_number(message: str) -> Union[str, None]: + """Returns number only i.e. + jt-100 -> 100 + JT100 -> 100 + 100 -> 100 + pc200 -> 200 + returns None if not found + """ + + replace = ["-", "jt", "ac", "lc", "pw"] + + match = re.search(r"\b(jt|ac|pw|lc)?-?(\d+)\b", message, re.IGNORECASE) + + if match: + matched_str = match.group(0) + for query in replace: + matched_str = matched_str.lower().replace(query, "") + return matched_str + + return None 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 264aba5386e..887a5bc2f78 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -1512,9 +1512,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. @@ -1608,6 +1613,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() @@ -1628,6 +1635,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..62ed1f8d9ef 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, Union 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,124 @@ def follow_up_absence_emails(): [student.notification_email], bcc=True, ) + + +@shared_task +def push_eighth_reminder_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification reminders to sign up, specified number of minutes prior to blocks locking + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + 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) + + if return_result: + return notification_datetime + + 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]), + }, + ) + return None + + +@shared_task +def push_glance_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification to each user containing their 'glance' + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + if schedule: + today = Day.objects.today() + if today: + today_8 = 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) + first_start_date = datetime.datetime.combine(timezone_now, first_start_time) - datetime.timedelta(minutes=10) + aware_first_start_date = timezone.make_aware(first_start_date, timezone.get_current_timezone()) + + if return_result: + return aware_first_start_date + + 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"), + }, + ) + + return None + + +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/admin.py b/intranet/apps/notifications/admin.py new file mode 100644 index 00000000000..ba152d3b23f --- /dev/null +++ b/intranet/apps/notifications/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from intranet.apps.notifications.models import WebPushNotification + + +class WebPushNotificationAdmin(admin.ModelAdmin): + search_fields = ["title", "user_sent__username", "target"] + + +admin.site.register(WebPushNotification, WebPushNotificationAdmin) 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..32284780188 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,78 @@ 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() + + def __str__(self): + return f"Notification sent to {self.target} at {self.date_sent} ({self.title})" 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..a4ab69201b5 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: # pylint: disable=broad-except # Lots of things can go wrong with individual device subscriptions + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + 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: # pylint: disable=broad-except + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + 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: # pylint: disable=broad-except + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + 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..96c27da0ef5 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,18 @@ 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/schedule$", views.webpush_schedule_view, name="notif_webpush_schedule_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..d7350a39925 --- /dev/null +++ b/intranet/apps/notifications/utils.py @@ -0,0 +1,32 @@ +import datetime +from typing import Dict + +from intranet import settings +from intranet.apps.bus.tasks import push_bus_notifications +from intranet.apps.eighth.tasks import push_eighth_reminder_notifications, push_glance_notifications + + +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 + + +def return_all_notification_schedules() -> Dict[str, Dict[str, datetime.datetime]]: + schedules = { + "Bus": {}, + "Eighth": {}, + } + schedules["Bus"]["Bus location notification @ dismissal"] = push_bus_notifications(True, True) + schedules["Eighth"][f"Sign up reminder before blocks lock @ {settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} min before"] = ( + push_eighth_reminder_notifications(True, True) + ) + schedules["Eighth"]["Glance notification @ eighth period start"] = push_glance_notifications(True, True) + + return schedules diff --git a/intranet/apps/notifications/views.py b/intranet/apps/notifications/views.py index 0e8c6da9cae..cfb66151ce4 100644 --- a/intranet/apps/notifications/views.py +++ b/intranet/apps/notifications/views.py @@ -1,16 +1,28 @@ 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 ...celery import app +from ..bus.tasks import push_bus_notifications +from ..eighth.tasks import push_eighth_reminder_notifications, push_glance_notifications +from ..schedule.models import Day 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 +from .utils import return_all_notification_schedules logger = logging.getLogger(__name__) @@ -106,7 +118,7 @@ def chrome_setup_view(request): @login_required def gcm_list_view(request): - if not request.user.has_admin_permission("notifications"): + if not request.user.is_notifications_admin: return redirect("index") gcm_notifs = GCMNotification.objects.all().order_by("-time") posts = [] @@ -163,7 +175,7 @@ def gcm_post(nc_users, data, user=None, request=None): @login_required def gcm_post_view(request): - if not request.user.has_admin_permission("notifications"): + if not request.user.is_notifications_admin: return redirect("index") try: has_tokens = settings.GCM_AUTH_KEY and settings.GCM_PROJECT_ID @@ -200,3 +212,126 @@ 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.is_notifications_admin: + 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.is_notifications_admin: + 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.is_notifications_admin: + 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 webpush_schedule_view(request): + if not request.user.is_notifications_admin: + return redirect("index") + + if request.method == "POST": + push_eighth_reminder_notifications.delay(True) + push_glance_notifications.delay(True) + push_bus_notifications.delay(True) + + messages.success(request, "Rescheduled tasks.") + + day = Day.objects.today() + + context = { + "schedules": return_all_notification_schedules(), + "day": day if day else "today: no schedule", + "scheduled_tasks": app.control.inspect().scheduled(), + } + + return render(request, "notifications/webpush_schedule.html", context) + + +def serve_serviceworker(request): + file_path = os.path.join(settings.STATICFILES_DIRS[0], "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..7a31df5eb81 --- /dev/null +++ b/intranet/apps/polls/notifications.py @@ -0,0 +1,36 @@ +from celery import shared_task +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 + + +@shared_task +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..d817e0cfc06 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.apply_async(args=(instance,), eta=instance.start_time) + 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..946047e6aeb 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,24 @@ 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" + browser_supported = supports_webpush_notifications(user_agent) + 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 +419,11 @@ 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, + "browser_supported": browser_supported, + "ENABLE_WAITLIST": settings.ENABLE_WAITLIST, } return render(request, "preferences/preferences.html", context) @@ -403,3 +461,17 @@ def privacy_options_view(request): else: context = {"profile_user": user} return render(request, "preferences/privacy_options.html", context) + + +def supports_webpush_notifications(user_agent): + """Detect browsers that support webpush notifications.""" + if (user_agent.browser.family in ("Chrome", "Opera")) and user_agent.browser.version[0] >= 42: + return True + elif user_agent.browser.family == "Firefox" and user_agent.browser.version[0] >= 44: + return True + elif "Safari" in user_agent.browser.family and user_agent.os.family == "iOS" and user_agent.os.version[0] >= 16 and user_agent.os.version[1] >= 4: + return True + elif "Safari" in user_agent.browser.family and user_agent.os.family == "macOS" and user_agent.browser.version[0] >= 16: + return True + else: + return False diff --git a/intranet/apps/schedule/models.py b/intranet/apps/schedule/models.py index 95cfd3a2794..7f60b67de2d 100644 --- a/intranet/apps/schedule/models.py +++ b/intranet/apps/schedule/models.py @@ -144,6 +144,11 @@ def end_time(self): """Return time the school day ends""" return self.day_type.end_time + @property + def end_datetime(self): + """Return a timezone aware datetime of when the school day ends""" + return timezone.make_aware(self.day_type.end_time.date_obj(timezone.now()), timezone.get_current_timezone()) + def __str__(self): return f"{self.date}: {self.day_type}" diff --git a/intranet/apps/templatetags/dates.py b/intranet/apps/templatetags/dates.py index 076213f5103..096e2ff1e67 100644 --- a/intranet/apps/templatetags/dates.py +++ b/intranet/apps/templatetags/dates.py @@ -1,6 +1,7 @@ import logging from datetime import datetime +from dateutil import parser from django import template from django.utils import timezone @@ -73,3 +74,13 @@ def fuzzy_date(date): return date.strftime("next %A") else: return date.strftime("%A, %B %d, %Y") + + +@register.filter() +def strftime(date, date_format: str = "%B %d, %Y at %I:%M %p"): + try: + date = parser.parse(date) + + return date.strftime(date_format) + except (ValueError, TypeError): + return date 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 4ff84fe0bde..23a06720493 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. @@ -1094,6 +1106,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 492fc397c56..e3e76d3380a 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 = [ @@ -688,6 +720,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) @@ -935,6 +968,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=1), + "args": [True], + }, + "push-glance-notifications": { + "task": "intranet.apps.eighth.tasks.push_glance_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=1), + "args": [True], + }, + "push-bus-notifications": { + "task": "intranet.apps.bus.tasks.push_bus_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=1), + "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..398b91399a6 100644 --- a/intranet/static/css/dark/preferences.scss +++ b/intranet/static/css/dark/preferences.scss @@ -3,3 +3,54 @@ 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; +} + +.popup-content { + display: none; + position: absolute; + width: 200px; + padding: 10px; + background-color: #3e3e3e; + border: 1px solid #2c2c2c; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.popup-content p { + color: white; +} diff --git a/intranet/static/css/preferences.scss b/intranet/static/css/preferences.scss index 6150b0afebe..f7ed4a4d5cd 100644 --- a/intranet/static/css/preferences.scss +++ b/intranet/static/css/preferences.scss @@ -70,3 +70,61 @@ 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.9); + 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; +} + +.popup-link { + font-size: 12px; + margin-top: 3px; +} + +.popup-content { + display: none; + position: absolute; + width: 200px; + padding: 10px; + background-color: #f9f9f9; + border: 1px solid #ccc; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.popup-content p { + color: black; + pointer-events: none; +} 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/serviceworker.js b/intranet/static/serviceworker.js index 8feb77e98e2..4ebd677f61e 100644 --- a/intranet/static/serviceworker.js +++ b/intranet/static/serviceworker.js @@ -1,81 +1,89 @@ -var self = this; -self.addEventListener('push', function(event) { - console.log('Received a push message', event); +async function getSilentPreference() { + return new Promise((resolve, reject) => { + const dbRequest = indexedDB.open("notificationPreferences", 1); - 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); - }); - } + dbRequest.onupgradeneeded = function(event) { + const db = event.target.result; + db.createObjectStore("preferences", { keyPath: "id" }); + }; + dbRequest.onsuccess = function(event) { + const db = event.target.result; + const transaction = db.transaction(["preferences"], "readonly"); + const store = transaction.objectStore("preferences"); + const request = store.get("silentNotification"); - 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 - }); - } + request.onsuccess = function() { + if (request.result && request.result.silent) { + resolve(request.result.silent); + } else { + resolve(false); + } + }; -}); + request.onerror = function() { + resolve(false); + }; + }; -self.addEventListener('notificationclick', function(event) { - var tag = event.notification.tag; - console.log('Notification click: ', tag); + dbRequest.onerror = function() { + resolve(false); + }; + }); +} - event.notification.close(); +self.addEventListener("push", function(event) { + const data = event.data.json(); + let options = { + body: data.body, + icon: data.icon, + badge: data.badge, + data: { + url: data.data.url + }, + }; - 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); - } - } + getSilentPreference().then(function (silent) { + options["silent"] = silent; + self.registration.showNotification(data.title, options).then((r) => {}); + }) +}); - console.log("tagUrl: ", tagUrl); +// Immediately replace any old service worker(s) +self.addEventListener("install", function (event) { + self.skipWaiting(); +}); +self.addEventListener("notificationclick", function(event) { + event.notification.close(); 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); - } - }) + // eslint-disable-next-line no-undef + clients.openWindow(event.notification.data.url) ); -}) \ No newline at end of file +}); + +// 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/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..31b4174a809 --- /dev/null +++ b/intranet/templates/notifications/manage.html @@ -0,0 +1,33 @@ +{% 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/notifications/webpush_schedule.html b/intranet/templates/notifications/webpush_schedule.html new file mode 100644 index 00000000000..37e5ac667e7 --- /dev/null +++ b/intranet/templates/notifications/webpush_schedule.html @@ -0,0 +1,83 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} +{% load dates %} + +{% block title %} + {{ block.super }} - WebPush Notifications Schedule +{% 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 %} +
+

Push Notifications Schedule for {{ day }}

+ {% for key, value in schedules.items %} +

{{ key }}

+ + + + + + + + {% for key, val in value.items %} + + + + + {% endfor %} +
NameDate
{{ key }}{{ val }}
+ {% endfor %} +

Scheduled tasks

+ + + + + + + + + + + + + + {% for worker, tasks in scheduled_tasks.items %} + {% for task in tasks %} + + + + + + + + + + {% endfor %} + {% empty %} + + + + {% endfor %} +
WorkerETAArgumentsTask IDTask NameHostnameAcknowledged
{{ worker }}{{ task.eta|strftime }}{{ task.request.args }}, {{ task.request.kwargs }}{{ task.request.id }}{{ task.request.name }}{{ task.request.hostname }}{{ task.request.acknowledged }}
No scheduled tasks.
+
+ {% csrf_token %} + +
+

Warning: This action may cause double notifications to be sent if they are already scheduled. Only use this when no notification tasks are already scheduled

+
+{% endblock %} diff --git a/intranet/templates/preferences/preferences.html b/intranet/templates/preferences/preferences.html index a5e236c0bff..678cb6f8364 100644 --- a/intranet/templates/preferences/preferences.html +++ b/intranet/templates/preferences/preferences.html @@ -21,44 +21,349 @@ + {% endblock %} @@ -92,39 +397,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 +524,48 @@

Dark Mode

+ diff --git a/intranet/urls.py b/intranet/urls.py index 4a1b4d21b6b..a9d6b1aa714 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")), @@ -73,6 +73,13 @@ urlpatterns += [re_path(r"^__debug__/", include(debug_toolbar.urls))] # type: ignore +if not settings.PRODUCTION: + urlpatterns += [re_path( + r"^serviceworker\.js$", + views.serve_serviceworker, + name="serve service worker" + )] + handler404 = handle_404_view handler500 = handle_500_view handler503 = handle_503_view # maintenance mode 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