diff --git a/.gitignore b/.gitignore
index 18e47145c08..f8181dea159 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,3 +56,10 @@ package-lock.json
 # Virtual environments
 venv/
 .venv/
+
+# Webpush
+/keys/webpush/
+/keys/
+
+# Keys
+*.pem
\ No newline at end of file
diff --git a/config/docker/initial_setup.sh b/config/docker/initial_setup.sh
index 3786433fc8b..e3c22fc6716 100755
--- a/config/docker/initial_setup.sh
+++ b/config/docker/initial_setup.sh
@@ -49,3 +49,6 @@ python3 -u manage.py import_sports $(date +%m)
 
 echo -e "${BLUE}${BOLD}Creating CSL apps...${CLEAR}"
 python3 -u manage.py dev_create_cslapps
+
+echo -e "${BLUE}${BOLD}Generating vapid keys...${CLEAR}"
+python3 create_vapid_keys.py
\ No newline at end of file
diff --git a/config/scripts/create_vapid_keys.py b/config/scripts/create_vapid_keys.py
new file mode 100644
index 00000000000..546efa560db
--- /dev/null
+++ b/config/scripts/create_vapid_keys.py
@@ -0,0 +1,25 @@
+import base64
+import os
+
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from py_vapid import Vapid
+
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+os.makedirs(os.path.join(PROJECT_ROOT, "keys", "webpush"))
+
+# Generate VAPID key pair
+vapid = Vapid()
+vapid.generate_keys()
+
+# Get public and private keys for the vapid key pair
+vapid.save_public_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "public_key.pem"))
+public_key_bytes = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
+
+vapid.save_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "private_key.pem"))
+
+
+# Convert the public key to applicationServerKey format
+application_server_key = base64.urlsafe_b64encode(public_key_bytes).replace(b"=", b"").decode("utf8")
+
+with open(os.path.join(PROJECT_ROOT, "keys", "webpush", "ApplicationServerKey.key"), "w", encoding="utf-8") as f:
+    f.write(application_server_key)
diff --git a/cron/eighth-absence.sh b/cron/eighth-absence.sh
index 852e13149e7..4b55b2e7c01 100755
--- a/cron/eighth-absence.sh
+++ b/cron/eighth-absence.sh
@@ -4,4 +4,5 @@
 timestamp=$(date +"%Y-%m-%d-%H%M")
 cd /usr/local/www/intranet3
 ./cron/env.sh ./manage.py absence_email --silent
-echo "Absence email sent at $timestamp." >> /var/log/ion/email.log
+./cron/env.sh ./manage.py absence_notify --silent
+echo "Absence email and push notification sent at $timestamp." >> /var/log/ion/email.log
diff --git a/docs/sourcedoc/intranet.apps.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.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 2099248f7ef..273fd82f5cc 100644
--- a/intranet/apps/announcements/forms.py
+++ b/intranet/apps/announcements/forms.py
@@ -12,7 +12,12 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above."
 
-        self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email."
+        self.fields["notify_post"].help_text = (
+            "If this box is checked, students who have signed up for email "
+            "notifications will receive an email "
+            "and those who have signed up for push notifications will receive a "
+            "push notification."
+        )
 
         self.fields["notify_email_all"].help_text = (
             "This will send an email notification to all of the users who can see this post. This option "
@@ -41,7 +46,12 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. To change this, click in the box above."
 
-        self.fields["notify_post_resend"].help_text = "If this box is checked, students who have signed up for notifications will receive an email."
+        self.fields["notify_post_resend"].help_text = (
+            "If this box is checked, students who have signed up for email "
+            "notifications will receive an email "
+            "and those who have signed up for push notifications will "
+            "receive a push notification."
+        )
 
         self.fields["notify_email_all_resend"].help_text = (
             "This will resend an email notification to all of the users who can see this post. This option "
@@ -105,7 +115,12 @@ class AnnouncementAdminForm(forms.Form):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email."
+        self.fields["notify_post"].help_text = (
+            "If this box is checked, students who have signed up for email "
+            "notifications will receive an email "
+            "and those who have signed up for push notifications will receive a "
+            "push notification."
+        )
         self.fields["notify_email_all"].help_text = (
             "This will send an email notification to all of the users who can see this post. This option "
             "does NOT take users' email notification preferences into account, so please use with care."
diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py
index 51e6d486842..84a2dddeaa8 100644
--- a/intranet/apps/announcements/notifications.py
+++ b/intranet/apps/announcements/notifications.py
@@ -7,12 +7,18 @@
 from django.contrib import messages
 from django.contrib.auth import get_user_model
 from django.core import exceptions
+from django.db.models import Q
 from django.urls import reverse
+from django.utils.html import strip_tags
+from push_notifications.models import WebPushDevice
 from requests_oauthlib import OAuth1
 from sentry_sdk import capture_exception
 
 from ...utils.date import get_senior_graduation_year
-from ..notifications.tasks import email_send_task
+from ..notifications.tasks import email_send_task, send_bulk_notification
+from ..notifications.utils import truncate_content, truncate_title
+from ..users.models import User
+from .models import Announcement
 
 logger = logging.getLogger(__name__)
 
@@ -135,7 +141,7 @@ def announcement_posted_email(request, obj, send_all=False):
                 emails.append(u.notification_email)
                 users_send.append(u)
 
-        if not settings.PRODUCTION and len(emails) > 3:
+        if not settings.PRODUCTION and len(emails) > 3 and not settings.FORCE_EMAIL_SEND:
             raise exceptions.PermissionDenied("You're about to email a lot of people, and you aren't in production!")
 
         base_url = request.build_absolute_uri(reverse("index"))
@@ -200,3 +206,27 @@ def notify_twitter(status):
     req = requests.post(url, data=data, auth=auth, timeout=15)
 
     return req.text
+
+
+def announcement_posted_push_notification(obj: Announcement) -> None:
+    """Send a (Web)push notification to users when an announcement is posted.
+
+    obj: The announcement object
+
+    """
+
+    if not obj.groups.all():
+        users = User.objects.filter(push_notification_preferences__announcement_notifications=True)
+        devices = WebPushDevice.objects.filter(user__in=users)
+    else:
+        users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__announcement_notifications=True))
+        devices = WebPushDevice.objects.filter(user__in=users)
+
+    send_bulk_notification.delay(
+        filtered_objects=devices,
+        title=f"Announcement: {truncate_title(obj.title)} ({obj.get_author()})",
+        body=truncate_content(strip_tags(obj.content_no_links)),
+        data={
+            "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("view_announcement", args=[obj.id]),
+        },
+    )
diff --git a/intranet/apps/announcements/views.py b/intranet/apps/announcements/views.py
index b1f92231e0d..5ae71a8bd3b 100644
--- a/intranet/apps/announcements/views.py
+++ b/intranet/apps/announcements/views.py
@@ -19,6 +19,7 @@
     admin_request_announcement_email,
     announcement_approved_email,
     announcement_posted_email,
+    announcement_posted_push_notification,
     announcement_posted_twitter,
     request_announcement_email,
 )
@@ -48,6 +49,7 @@ def announcement_posted_hook(request, obj):
     """
     if obj.notify_post:
         announcement_posted_twitter(request, obj)
+        announcement_posted_push_notification(obj)
         try:
             notify_all = obj.notify_email_all
         except AttributeError:
diff --git a/intranet/apps/api/urls.py b/intranet/apps/api/urls.py
index 2c14c24eb07..2daca45a4ec 100644
--- a/intranet/apps/api/urls.py
+++ b/intranet/apps/api/urls.py
@@ -4,6 +4,7 @@
 from ..bus import api as bus_api
 from ..eighth.views import api as eighth_api
 from ..emerg import api as emerg_api
+from ..notifications import api as notification_api
 from ..schedule import api as schedule_api
 from ..users import api as users_api
 from .views import api_root
@@ -43,4 +44,15 @@
     re_path(r"^/emerg$", emerg_api.emerg_status, name="api_emerg_status"),
     re_path(r"^/bus$", bus_api.RouteList.as_view(), name="api_bus_list"),
     re_path(r"^/bus/(?P<pk>\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..5764d5bb065 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_delayed_bus_notifications
 
 logger = logging.getLogger(__name__)
 
@@ -49,6 +51,11 @@ def receive_json(self, content):  # pylint: disable=arguments-differ
                         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..a7455efd026 100644
--- a/intranet/apps/bus/tasks.py
+++ b/intranet/apps/bus/tasks.py
@@ -1,6 +1,13 @@
 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
 
 logger = get_task_logger(__name__)
@@ -12,3 +19,62 @@ def reset_routes() -> None:
 
     for route in Route.objects.all():
         route.reset_status()
+
+
+@shared_task
+def push_bus_notifications(schedule: bool = False) -> None:
+    if schedule:
+        day = Day.objects.today()
+        if day is not None:
+            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
+                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"),
+                                },
+                            )
+
+
+@shared_task
+def push_delayed_bus_notifications(bus_number) -> 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"),
+        },
+    )
+
+
+def convert_dataset(dataset):
+    # Convert each number to the format "_number" and return as a set
+    # because that's how the ID spots are named
+    return {"_" + str(number) for number in dataset}
diff --git a/intranet/apps/eighth/management/commands/absence_notify.py b/intranet/apps/eighth/management/commands/absence_notify.py
new file mode 100644
index 00000000000..8c2b441863e
--- /dev/null
+++ b/intranet/apps/eighth/management/commands/absence_notify.py
@@ -0,0 +1,29 @@
+from django.core.management.base import BaseCommand
+
+from intranet.apps.eighth.models import EighthSignup
+from intranet.apps.eighth.notifications import absence_notification
+
+
+class Command(BaseCommand):
+    help = "Push notify users who have an Eighth Period absence (via Webpush.)"
+
+    def add_arguments(self, parser):
+        parser.add_argument("--silent", action="store_true", dest="silent", default=False, help="Be silent.")
+
+        parser.add_argument("--pretend", action="store_true", dest="pretend", default=False, help="Pretend, and don't actually do anything.")
+
+    def handle(self, *args, **options):
+        log = not options["silent"]
+
+        absences = EighthSignup.objects.get_absences().filter(absence_notified=False)
+
+        for signup in absences:
+            if log:
+                self.stdout.write(str(signup))
+            if not options["pretend"]:
+                absence_notification(signup)
+                signup.absence_notified = True
+                signup.save()
+
+        if log:
+            self.stdout.write("Done.")
diff --git a/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py
new file mode 100644
index 00000000000..c4d02fdd164
--- /dev/null
+++ b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.25 on 2024-07-25 23:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('eighth', '0065_auto_20220903_0038'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='eighthsignup',
+            name='absence_notified',
+            field=models.BooleanField(blank=True, default=False),
+        ),
+        migrations.AddField(
+            model_name='historicaleighthsignup',
+            name='absence_notified',
+            field=models.BooleanField(blank=True, default=False),
+        ),
+    ]
diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py
index 6fca981ebe8..73f7d481d50 100644
--- a/intranet/apps/eighth/models.py
+++ b/intranet/apps/eighth/models.py
@@ -1503,9 +1503,14 @@ def cancel(self):
             self.save(update_fields=["cancelled"])
 
             if not self.is_both_blocks or self.block.block_letter != "B":
-                from .notifications import activity_cancelled_email  # pylint: disable=import-outside-toplevel,cyclic-import
+                # pylint: disable=import-outside-toplevel,cyclic-import
+                from .notifications import (
+                    activity_cancelled_email,
+                    activity_cancelled_notification,
+                )
 
                 activity_cancelled_email(self)
+                activity_cancelled_notification(self)
 
     def uncancel(self):
         """Uncancel an EighthScheduledActivity.
@@ -1599,6 +1604,8 @@ class EighthSignup(AbstractBaseEighthModel):
             Whether the student has dismissed the absence notification.
         absence_emailed
             Whether the student has been emailed about the absence.
+        absence_notified
+            Whether the student has received a push notification about their absence
     """
 
     objects = EighthSignupManager()
@@ -1619,6 +1626,7 @@ class EighthSignup(AbstractBaseEighthModel):
     was_absent = models.BooleanField(default=False, blank=True)
     absence_acknowledged = models.BooleanField(default=False, blank=True)
     absence_emailed = models.BooleanField(default=False, blank=True)
+    absence_notified = models.BooleanField(default=False, blank=True)
 
     archived_was_absent = models.BooleanField(default=False, blank=True)
 
diff --git a/intranet/apps/eighth/notifications.py b/intranet/apps/eighth/notifications.py
index 738c475a039..6ff02fd5422 100644
--- a/intranet/apps/eighth/notifications.py
+++ b/intranet/apps/eighth/notifications.py
@@ -1,9 +1,11 @@
 import logging
 
 from django.urls import reverse
+from push_notifications.models import WebPushDevice
 
+from ... import settings
 from ..notifications.emails import email_send
-from ..notifications.tasks import email_send_task
+from ..notifications.tasks import email_send_task, send_bulk_notification, send_notification_to_user
 from .models import EighthScheduledActivity, EighthSignup
 
 logger = logging.getLogger(__name__)
@@ -69,7 +71,7 @@ def activity_cancelled_email(sched_act: EighthScheduledActivity):
 
     emails = list({signup.user.notification_email for signup in sched_act.eighthsignup_set.filter(user__receive_eighth_emails=True)})
 
-    base_url = "https://ion.tjhsst.edu"
+    base_url = settings.PUSH_NOTIFICATIONS_BASE_URL
 
     data = {"sched_act": sched_act, "date_str": date_str, "base_url": base_url}
 
@@ -93,7 +95,7 @@ def absence_email(signup, use_celery=True):
 
     # We can't build an absolute URI because this isn't being executed
     # in the context of a Django request
-    base_url = "https://ion.tjhsst.edu"  # request.build_absolute_uri(reverse('index'))
+    base_url = settings.PUSH_NOTIFICATIONS_BASE_URL  # request.build_absolute_uri(reverse('index'))
 
     data = {
         "user": user,
@@ -109,3 +111,31 @@ def absence_email(signup, use_celery=True):
         return None
     else:
         return email_send(*args)
+
+
+def activity_cancelled_notification(sched_act: EighthScheduledActivity):
+    date_str = sched_act.block.date.strftime("%A, %B %-d")
+    devices = WebPushDevice.objects.filter(user__in=sched_act.members.all())
+
+    send_bulk_notification.delay(
+        filtered_objects=devices,
+        title="Eighth Period Activity Cancelled",
+        body=f"The activity '{sched_act.activity.name}' was cancelled on {date_str} "
+        f"for {sched_act.block.block_letter}. You will need to select a new activity",
+        data={
+            "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[sched_act.block.id]),
+        },
+    )
+
+
+def absence_notification(signup: EighthSignup):
+    user = signup.user
+    if user.push_notification_preferences.is_subscribed:
+        send_notification_to_user.delay(
+            user=signup.user,
+            title="Eighth Period Absence",
+            body=f"You received an Eighth Period absence on {signup.scheduled_activity.block}",
+            data={
+                "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_absences"),
+            },
+        )
diff --git a/intranet/apps/eighth/tasks.py b/intranet/apps/eighth/tasks.py
index 20d4326e92c..a285b30fd7f 100644
--- a/intranet/apps/eighth/tasks.py
+++ b/intranet/apps/eighth/tasks.py
@@ -1,18 +1,23 @@
 import calendar
 import datetime
-from typing import Collection
+from typing import Any, Collection, List
 
 from celery import shared_task
 from celery.utils.log import get_task_logger
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.mail import EmailMessage
+from django.urls import reverse
 from django.utils import timezone
+from push_notifications.models import WebPushDevice
 
 from ...utils.helpers import join_nicely
 from ..groups.models import Group
 from ..notifications.emails import email_send
-from .models import EighthActivity, EighthRoom, EighthScheduledActivity
+from ..notifications.tasks import send_bulk_notification, send_notification_to_user
+from ..schedule.models import Day
+from ..users.models import User
+from .models import EighthActivity, EighthBlock, EighthRoom, EighthScheduledActivity
 
 logger = get_task_logger(__name__)
 
@@ -343,3 +348,103 @@ def follow_up_absence_emails():
                 [student.notification_email],
                 bcc=True,
             )
+
+
+@shared_task
+def push_eighth_reminder_notifications(schedule: bool = False) -> None:
+    """Send push notification reminders to sign up, specified number of minutes prior to blocks locking"""
+    if schedule:
+        block = EighthBlock.objects.get_blocks_today().first()
+
+        if block is not None:
+            # Get the time to send reminder notifications (PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES
+            # minutes prior to the block locking)
+            block_datetime = datetime.datetime.combine(timezone.now(), block.signup_time)
+            block_datetime = timezone.make_aware(block_datetime, timezone.get_current_timezone())
+            notification_datetime = block_datetime - datetime.timedelta(minutes=settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES)
+
+            push_eighth_reminder_notifications.apply_async(eta=notification_datetime)
+            logger.info("Push reminder notifications scheduled at %s for %s block (eighth reminder)", str(notification_datetime), block.block_letter)
+
+    else:
+        todays_blocks = EighthBlock.objects.get_blocks_today()
+
+        if todays_blocks is not None:
+            for block in todays_blocks:
+                unsigned_students = block.get_unsigned_students()
+
+                # We only want to send this notification to users who have enabled "eighth_reminder_notifications"
+                # in their preferences.
+                users_to_send = unsigned_students.filter(push_notification_preferences__eighth_reminder_notifications=True)
+
+                # No need to check if the user is subscribed since we are passing WebPushDevice objects directly
+                devices_to_send = WebPushDevice.objects.filter(user__in=users_to_send)
+
+                send_bulk_notification(
+                    filtered_objects=devices_to_send,
+                    title="Sign up for Eighth Period",
+                    body=f"You have not signed up for today's eighth period ({block.block_letter} block). "
+                    f"Sign ups close in {settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} minutes.",
+                    data={
+                        "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[block.id]),
+                    },
+                )
+
+
+@shared_task
+def push_glance_notifications(schedule: bool = False) -> None:
+    if schedule:
+        today_8 = Day.objects.today().day_type.blocks.filter(name__contains="8")
+        if today_8:
+            timezone_now = timezone.now().today()
+            first_start_time = datetime.time(today_8[0].start.hour, today_8[0].start.minute)
+            last_start_time = datetime.time(today_8.last().start.hour, today_8.last().start.minute)
+            first_start_date = datetime.datetime.combine(timezone_now, first_start_time)
+            last_start_date = datetime.datetime.combine(timezone_now, last_start_time)
+            if (
+                first_start_date - datetime.timedelta(minutes=30)
+                < datetime.datetime.combine(timezone_now, timezone.now().time())
+                < last_start_date + datetime.timedelta(minutes=20)
+            ):
+                first_start_date = timezone.make_aware(first_start_date, timezone.get_current_timezone())
+
+                push_glance_notifications.apply_async(eta=first_start_date)
+                logger.info("Push glance notifications scheduled at %s (glance)", str(first_start_date))
+    else:
+        users_to_send = User.objects.filter(push_notification_preferences__glance_notifications=True)
+        blocks = EighthBlock.objects.get_blocks_today()
+
+        if blocks:
+            for user in users_to_send:
+                sch_acts = []
+                for b in blocks:
+                    try:
+                        act = user.eighthscheduledactivity_set.get(block=b)
+                        if act.activity.name != "z - Hybrid Sticky":
+                            sch_acts.append(
+                                [b, act, ", ".join([r.name for r in act.get_true_rooms()]), ", ".join([s.name for s in act.get_true_sponsors()])]
+                            )
+                    except EighthScheduledActivity.DoesNotExist:
+                        sch_acts.append([b, None])
+
+                body = "\n".join(
+                    [
+                        f"{s[0].hybrid_text if list_index_exists(0, s) else None} block: "
+                        f"{s[1].full_title if list_index_exists(1, s) else None} "
+                        f"(Room {s[2] if list_index_exists(2, s) else None})"
+                        for s in sch_acts
+                    ]
+                )
+
+                send_notification_to_user(
+                    user=user,
+                    title="Eighth Period Glance",
+                    body=body,
+                    data={
+                        "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_location"),
+                    },
+                )
+
+
+def list_index_exists(index: int, list_to_check: List[Any]) -> bool:
+    return len(list_to_check) > index and list_to_check[index]
diff --git a/intranet/apps/notifications/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<intranet.apps.users.models.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..b8c0dab941a 100644
--- a/intranet/apps/notifications/urls.py
+++ b/intranet/apps/notifications/urls.py
@@ -1,4 +1,5 @@
 from django.urls import re_path
+from django.views.generic import TemplateView
 
 from . import views
 
@@ -8,4 +9,17 @@
     re_path(r"^/chrome/getdata$", views.chrome_getdata_view, name="notif_chrome_getdata"),
     re_path(r"^/gcm/post$", views.gcm_post_view, name="notif_gcm_post"),
     re_path(r"^/gcm/list$", views.gcm_list_view, name="notif_gcm_list"),
+    re_path(r"^/webpush/list$", views.webpush_list_view, name="notif_webpush_list"),
+    re_path(r"^/webpush/list/(?P<model_id>\d+)$", views.webpush_device_info_view, name="notif_webpush_device_view"),
+    re_path(
+        r"^/webpush/ios/setup$",
+        TemplateView.as_view(template_name="notifications/ios_notifications_guide.html", content_type="text/html"),
+        name="ios_notif_setup",
+    ),
+    re_path(r"^/webpush/post$", views.webpush_post_view, name="notif_webpush_post_view"),
+    re_path(
+        r"^/webpush/manage$",
+        TemplateView.as_view(template_name="notifications/manage.html", content_type="text/html"),
+        name="manage_push_notifs",
+    ),
 ]
diff --git a/intranet/apps/notifications/utils.py b/intranet/apps/notifications/utils.py
new file mode 100644
index 00000000000..1a9edfaf1d3
--- /dev/null
+++ b/intranet/apps/notifications/utils.py
@@ -0,0 +1,10 @@
+def truncate_content(content: str) -> str:
+    if len(content) > 200:
+        return content[:200] + "..."
+    return content
+
+
+def truncate_title(title: str) -> str:
+    if len(title) > 50:
+        return title[:50] + "..."
+    return title
diff --git a/intranet/apps/notifications/views.py b/intranet/apps/notifications/views.py
index 0e8c6da9cae..6ac77adc4c1 100644
--- a/intranet/apps/notifications/views.py
+++ b/intranet/apps/notifications/views.py
@@ -1,16 +1,23 @@
 import json
 import logging
+import os
 
 import requests
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
-from django.http import HttpResponse
+from django.core.paginator import Paginator
+from django.db.models import Q
+from django.http import FileResponse, HttpResponse
 from django.shortcuts import redirect, render
 from django.views.decorators.csrf import csrf_exempt
+from push_notifications.models import WebPushDevice
 
 from ..schedule.notifications import chrome_getdata_check
-from .models import GCMNotification, NotificationConfig
+from ..users.models import User
+from .forms import SendPushNotificationForm
+from .models import GCMNotification, NotificationConfig, WebPushNotification
+from .tasks import send_bulk_notification
 
 logger = logging.getLogger(__name__)
 
@@ -200,3 +207,104 @@ def get_gcm_schedule_uids():
     nc_all = NotificationConfig.objects.exclude(gcm_token=None).exclude(gcm_optout=True)
     nc = nc_all.filter(user__receive_schedule_notifications=True)
     return nc.values_list("id", flat=True)
+
+
+@login_required
+def webpush_list_view(request):
+    if not request.user.has_admin_permission("notifications"):
+        return redirect("index")
+    notifications = WebPushNotification.objects.all().order_by("-date_sent")
+
+    paginator = Paginator(notifications, 20)
+
+    page_number = request.GET.get("page")
+    page_obj = paginator.get_page(page_number)
+
+    return render(
+        request,
+        "notifications/webpush_list.html",
+        {
+            "notifications": notifications,
+            "page_obj": page_obj,
+            "targets": WebPushNotification.Targets,
+            "paginator": paginator,
+        },
+    )
+
+
+@login_required
+def webpush_device_info_view(request, model_id=None):
+    if not request.user.has_admin_permission("notifications"):
+        return redirect("index")
+    notifications = WebPushNotification.objects.filter(id=model_id).first()
+    notification_target = notifications.target
+
+    if notifications is not None:
+        if notification_target == WebPushNotification.Targets.DEVICE:
+            notifications = notifications.device_sent
+        elif notification_target == WebPushNotification.Targets.DEVICE_QUERYSET:
+            notifications = notifications.device_queryset_sent.all()
+        else:
+            messages.error(request, "The notification type cannot be found or is 'Targets.USER'")
+            return redirect("index")
+    else:
+        messages.error(request, f"Can't find notification with id {model_id}")
+        return redirect("index")
+
+    if notification_target == WebPushNotification.Targets.DEVICE_QUERYSET:
+        paginator = Paginator(notifications, 10)
+        page_number = request.GET.get("page")
+        page_obj = paginator.get_page(page_number)
+    else:
+        page_obj = None
+        paginator = None
+
+    return render(
+        request,
+        "notifications/webpush_device_info.html",
+        {
+            "notifications": notifications,
+            "page_obj": page_obj,
+            "paginator": paginator,
+        },
+    )
+
+
+@login_required()
+def webpush_post_view(request):
+    if not request.user.has_admin_permission("notifications"):
+        return redirect("index")
+
+    if request.method == "POST":
+        form = SendPushNotificationForm(data=request.POST)
+
+        if form.is_valid():
+            if not form.cleaned_data["users"].exists() and not form.cleaned_data["groups"].exists():
+                devices = WebPushDevice.objects.all()
+            else:
+                group_users = User.objects.filter(groups__in=form.cleaned_data["groups"])
+
+                devices = WebPushDevice.objects.filter(Q(user__in=form.cleaned_data["users"]) | Q(user__in=group_users))
+
+            send_bulk_notification.delay(
+                title=form.cleaned_data["title"], body=form.cleaned_data["body"], data={"url": form.cleaned_data["url"]}, filtered_objects=devices
+            )
+
+            messages.success(request, "Sent post notification.")
+        else:
+            messages.error(request, "Form invalid.")
+
+    send_push_notification_form = SendPushNotificationForm()
+
+    return render(
+        request,
+        "notifications/webpush_post.html",
+        {
+            "form": send_push_notification_form,
+        },
+    )
+
+
+def serve_serviceworker(request):
+    file_path = os.path.join(settings.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..efa45a35b37
--- /dev/null
+++ b/intranet/apps/polls/notifications.py
@@ -0,0 +1,34 @@
+from django.db.models import Q
+from django.urls import reverse
+from django.utils.html import strip_tags
+from push_notifications.models import WebPushDevice
+
+from intranet import settings
+from intranet.apps.notifications.tasks import send_bulk_notification
+from intranet.apps.notifications.utils import truncate_content, truncate_title
+from intranet.apps.polls.models import Poll
+from intranet.apps.users.models import User
+
+
+def send_poll_notification(obj: Poll) -> None:
+    """Send a (Web)push notification asking all users who can see the poll to vote
+
+    obj: The poll object
+
+    """
+
+    if not obj.groups.all():
+        users = User.objects.filter(push_notification_preferences__poll_notifications=True)
+        devices = WebPushDevice.objects.filter(user__in=users)
+    else:
+        users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__poll_notifications=True))
+        devices = WebPushDevice.objects.filter(user__in=users)
+
+    send_bulk_notification.delay(
+        filtered_objects=devices,
+        title=f"New Poll: {truncate_title(obj.title)}",
+        body=truncate_content(strip_tags(obj.description)),
+        data={
+            "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("poll_vote", args=[obj.id]),
+        },
+    )
diff --git a/intranet/apps/polls/views.py b/intranet/apps/polls/views.py
index f9274628039..c2c397ab6cf 100644
--- a/intranet/apps/polls/views.py
+++ b/intranet/apps/polls/views.py
@@ -21,6 +21,7 @@
 from ..auth.decorators import deny_restricted
 from .forms import PollForm
 from .models import Answer, Choice, Poll, Question
+from .notifications import send_poll_notification
 
 logger = logging.getLogger(__name__)
 
@@ -662,6 +663,9 @@ def add_poll_view(request):
 
             process_question_data(instance, question_data)
 
+            if request.POST.get("send_notification"):
+                send_poll_notification(instance)
+
             messages.success(request, "The poll has been created.")
             return redirect("polls")
     else:
diff --git a/intranet/apps/preferences/forms.py b/intranet/apps/preferences/forms.py
index 10e027f7822..583f5b13fbc 100644
--- a/intranet/apps/preferences/forms.py
+++ b/intranet/apps/preferences/forms.py
@@ -3,7 +3,9 @@
 from django import forms
 from django.contrib.auth import get_user_model
 
+from ... import settings
 from ..bus.models import Route
+from ..notifications.models import UserPushNotificationPreferences
 from ..users.models import Email, Grade, Phone, Website
 
 logger = logging.getLogger(__name__)
@@ -131,6 +133,32 @@ class Meta:
         fields = ["url"]
 
 
+class PushNotificationOptionsForm(forms.ModelForm):
+    class Meta:
+        model = UserPushNotificationPreferences
+        fields = [
+            "eighth_reminder_notifications",
+            "eighth_waitlist_notifications",
+            "glance_notifications",
+            "announcement_notifications",
+            "poll_notifications",
+            "bus_notifications",
+        ]
+
+        help_texts = {
+            "eighth_reminder_notifications": f"Receive reminder notifications to sign up for eighth period if you "
+            f"haven't signed up for one "
+            f"{settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} "
+            f"minutes prior to when blocks lock",
+            "eighth_waitlist_notifications": "Receive notifications when waitlisted for an activity. Must be enabled to use the waitlist feature",
+            "glance_notifications": "Receive your eighth period glance (a short message telling you which activities "
+            "you signed up for) as a notification when eighth period starts",
+            "announcement_notifications": "Receive notifications whenever an announcement is posted on Ion",
+            "poll_notifications": "Receive notifications whenever a poll you can vote in is available",
+            "bus_notifications": "Receive a notification at dismissal telling you your bus location and if it's delayed or not",
+        }
+
+
 PhoneFormset = forms.inlineformset_factory(get_user_model(), Phone, form=PhoneForm, extra=1)
 EmailFormset = forms.inlineformset_factory(get_user_model(), Email, form=EmailForm, extra=1)
 WebsiteFormset = forms.inlineformset_factory(get_user_model(), Website, form=WebsiteForm, extra=1)
diff --git a/intranet/apps/preferences/views.py b/intranet/apps/preferences/views.py
index 6bcbf78337c..7464d039f85 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 user_agent.browser.family == "Safari" and user_agent.os.family == "iOS" and user_agent.os.version[0] >= 16 and user_agent.os.version[1] >= 4:
+        return True
+    elif user_agent.browser.family == "Safari" 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/forms.py b/intranet/apps/templatetags/forms.py
index 56efe5f9f22..224b6e79929 100644
--- a/intranet/apps/templatetags/forms.py
+++ b/intranet/apps/templatetags/forms.py
@@ -32,3 +32,8 @@ def field_array_size(field):
         if re.match(rf"^{prefix}_(\d+)$", field_name):
             count += 1
     return count
+
+
+@register.filter
+def field_type(field):
+    return field.field.widget.__class__.__name__
diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py
index d592100c261..123bfcc1a83 100644
--- a/intranet/apps/users/models.py
+++ b/intranet/apps/users/models.py
@@ -21,6 +21,7 @@
 from ..bus.models import Route
 from ..eighth.models import EighthBlock, EighthSignup, EighthSponsor
 from ..groups.models import Group
+from ..notifications.models import UserPushNotificationPreferences
 from ..polls.models import Poll
 from ..preferences.fields import PhoneField
 
@@ -741,6 +742,17 @@ def is_board_admin(self) -> bool:
 
         return self.has_admin_permission("board")
 
+    @property
+    def is_notifications_admin(self) -> bool:
+        """Checks if user is a notifications admin.
+
+        Returns:
+            Whether this user is a notifications admin.
+
+        """
+
+        return self.has_admin_permission("notifications")
+
     @property
     def is_global_admin(self) -> bool:
         """Checks if user is a global admin.
@@ -1078,6 +1090,8 @@ def __getattr__(self, name):
             return UserProperties.objects.get_or_create(user=self)[0]
         elif name == "dark_mode_properties":
             return UserDarkModeProperties.objects.get_or_create(user=self)[0]
+        elif name == "push_notification_preferences":
+            return UserPushNotificationPreferences.objects.get_or_create(user=self)[0]
         raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
 
     def __str__(self):
diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py
index c073348baaa..297254c32b0 100644
--- a/intranet/settings/__init__.py
+++ b/intranet/settings/__init__.py
@@ -1,3 +1,5 @@
+# pylint: disable=C0302
+
 import datetime
 import logging
 import os
@@ -103,6 +105,8 @@
 # App and functionality availability toggles
 ENABLE_WAITLIST = False  # Eighth waitlist. WARNING: Enabling the waitlist causes severe performance issues
 
+ENABLE_WEBPUSH = True  # Enable webpush notifications
+
 ENABLE_BUS_APP = True
 ENABLE_BUS_DRIVER = True
 
@@ -303,6 +307,34 @@
     os.path.join(PROJECT_ROOT, "static")
 ]
 
+# Settings for Webpush (used in the django-push-notifications library)
+PUSH_NOTIFICATIONS_SETTINGS = {
+    "WP_PRIVATE_KEY": os.path.join(os.path.dirname(PROJECT_ROOT), "keys", "webpush", "private_key.pem"),
+    "WP_CLAIMS": {"sub": "mailto:intranet@tjhsst.edu"},
+}
+
+# Used in instances where the request object is not available, so we can't build an absolute URI
+PUSH_NOTIFICATIONS_BASE_URL = "https://ion.tjhsst.edu" if PRODUCTION else "http://localhost:8080"
+
+# How many minutes before an eighth period locks should notifications be sent out
+PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES = 40
+
+# Determines the name/location of each bus spot when sending Webpush notifications
+# Keys are lowercase so they don't look out of place when formatted into the notification body
+# The values are bus spot ID's. I.e. "_9" is written as 9
+
+PUSH_ROUTE_TRANSLATIONS = {
+    "curb, left section": {7, 8, 9},
+    "curb, middle section": {3, 4, 5, 6},
+    "curb, right section": {1, 2, 41},
+    "front row, left section": {19, 20, 21, 22},
+    "back row, left section": {42, 43, 44, 45},
+    "front row, middle section": {14, 15, 16, 17, 18},
+    "back row, middle section": {27, 28, 29, 30},
+    "front row, right section": {10, 11, 12, 13},
+    "back row, right section": {23, 24, 25, 26},
+}
+
 # List of finder classes that know how to find static files in
 # various locations.
 STATICFILES_FINDERS = [
@@ -687,6 +719,7 @@ def get_month_seconds():
     "simple_history",  # django-simple-history
     "django_referrer_policy",
     "django_user_agents",
+    "push_notifications",  # django-push-notifications
 ]
 
 # Django Channels Configuration (we use this for websockets)
@@ -934,6 +967,26 @@ def get_log(name):  # pylint: disable=redefined-outer-name; 'name' is used as th
         "schedule": celery.schedules.crontab(day_of_month=3, hour=1),
         "args": (),
     },
+    "push-eighth-reminder-notifications": {
+        "task": "intranet.apps.eighth.tasks.push_eighth_reminder_notifications",
+        "schedule": celery.schedules.crontab(hour=0, minute=0),
+        "args": [True],
+    },
+    "push-glance-notifications": {
+        "task": "intranet.apps.eighth.tasks.push_glance_notifications",
+        "schedule": celery.schedules.crontab(hour=0, minute=0),
+        "args": [True],
+    },
+    "push-bus-notifications": {
+        "task": "intranet.apps.bus.tasks.push_bus_notifications",
+        "schedule": celery.schedules.crontab(hour=0, minute=0),
+        "args": [True],
+    },
+    "remove-inactive-subscriptions": {
+        "task": "intranet.apps.notifications.tasks.remove_inactive_subscriptions",
+        "schedule": celery.schedules.crontab(day_of_week=0, hour=0, minute=0),
+        "args": (),
+    },
 }
 
 MAINTENANCE_MODE = False
diff --git a/intranet/static/css/dark/preferences.scss b/intranet/static/css/dark/preferences.scss
index f9f72d02142..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 @@ <h2>
                 <a href="/djangoadmin/oauth/cslapplication/">OAuth Applications</a><br>
                 <a href="/djangoadmin/printing/printjob/">Print Jobs</a><br>
             {% endif %}
+            {% if request.user.is_notifications_admin %}
+                <a href="{% url "manage_push_notifs" %}">Manage Push Notifications</a><br>
+            {% endif %}
         </td>
         <td>
             {% 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 }}
+    <style>
+        li {
+            font-size: 15px;
+        }
+    </style>
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+{% endblock %}
+
+{% block head %}
+    {% if dark_mode_enabled %}
+        {% stylesheet 'dark/base' %}
+        {% stylesheet 'dark/nav' %}
+    {% endif %}
+{% endblock %}
+
+{% block main %}
+    <div class="primary-content">
+        <h2>Add Ion to your home screen (iOS)</h2>
+        <p>Note: See
+            <a href="https://support.apple.com/guide/iphone/bookmark-favorite-webpages-iph42ab2f3a7/ios">Apple's instructions</a>
+        for the latest information</p>
+        <ol style="list-style: number; margin-left: 15px;">
+            <li>While viewing this website, tap the
+
+                <svg width="25px" height="25px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+                <g id="Communication / Share_iOS_Export">
+                <path id="Vector" d="M9 6L12 3M12 3L15 6M12 3V13M7.00023 10C6.06835 10 5.60241 10 5.23486 10.1522C4.74481 10.3552 4.35523 10.7448 4.15224 11.2349C4 11.6024 4 12.0681 4 13V17.8C4 18.9201 4 19.4798 4.21799 19.9076C4.40973 20.2839 4.71547 20.5905 5.0918 20.7822C5.5192 21 6.07899 21 7.19691 21H16.8036C17.9215 21 18.4805 21 18.9079 20.7822C19.2842 20.5905 19.5905 20.2839 19.7822 19.9076C20 19.4802 20 18.921 20 17.8031V13C20 12.0681 19.9999 11.6024 19.8477 11.2349C19.6447 10.7448 19.2554 10.3552 18.7654 10.1522C18.3978 10 17.9319 10 17 10" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+                </g>
+                </svg>
+
+                button in the menu bar.</li>
+            <li>Scroll down the list of options, and click 'Add to Home Screen'.</li>
+            <li>If you're trying to enable Push Notifications, open Ion from your home screen and subscribe via the preferences page.</li>
+        </ol>
+        <p style="font-size: 12px;">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</p>
+        <img src="{% static 'img/guides/add_to_home_screen_ios.png' %}" alt="Add to Home Screen Visual Image" height="700">
+    </div>
+{% endblock %}
diff --git a/intranet/templates/notifications/manage.html b/intranet/templates/notifications/manage.html
new file mode 100644
index 00000000000..e4c781e589b
--- /dev/null
+++ b/intranet/templates/notifications/manage.html
@@ -0,0 +1,32 @@
+{% extends "page_with_nav.html" %}
+{% load static %}
+{% load pipeline %}
+
+{% block title %}
+    {{ block.super }} - Manage Push Notifications
+{% endblock %}
+
+{% block css %}
+    {{ block.super }}
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+{% endblock %}
+
+{% block head %}
+    {% if dark_mode_enabled %}
+        {% stylesheet 'dark/base' %}
+        {% stylesheet 'dark/nav' %}
+    {% endif %}
+{% endblock %}
+
+{% block main %}
+    <div class="primary-content">
+        <h2>Manage Push Notifications</h2>
+        <ul>
+            <li><a href="{% url "notif_webpush_post_view" %}">Post a Notification</a></li>
+            <li><a href="{% url "notif_webpush_list" %}">Notifications Logs</a></li>
+        </ul>
+    </div>
+{% 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 %}
+    <div class="primary-content">
+        <h2>Devices Sent</h2>
+        <table class="fancy-table" style="margin-top:10px;">
+            <thead>
+                <tr>
+                    <th>Device ID</th>
+                    <th>Registration ID (Endpoint)</th>
+                    <th>Associated User</th>
+                </tr>
+            </thead>
+            {% if page_obj is not None %}
+                {% for item in page_obj.object_list %}
+                    <tr>
+                        <td>{{ item.id }}</td>
+                        <td>
+                            <a href="">{{ item.registration_id }}</a>
+                        </td>
+                        <td>
+                            <a href="{% url 'user_profile' user_id=item.user.id %}">{{ item.user }}</a>
+                        </td>
+                    </tr>
+                {% endfor %}
+            {% else %}
+                <tr>
+                    <td>{{ notifications.id }}</td>
+                    <td>
+                        <a href="">{{ notifications.registration_id }}</a>
+                    </td>
+                    <td>
+                        <a href="{% url 'user_profile' user_id=notifications.user.id %}">{{ notifications.user }}</a>
+                    </td>
+                </tr>
+            {% endif %}
+        </table>
+        {% if page_obj is not None %}
+            <div style="text-align:right">
+                <form action="" method="get" style="display:inline;float:right">
+                    <input name="page" type="number"
+                    min="1" max={{ page_obj.num_pages }} class="dashboard-textinput" style="width: 75px"
+                    placeholder={{ page_obj.number }}> of {{ page_obj.num_pages }}
+                    <input type="submit" value="Go"/>
+                </form>
+                {% if page_obj.has_previous %}
+                    <a href="?page={{ page_obj.previous_page_number }}">
+                        <input type="button" value="<"/>
+                    </a>
+                {% endif %}
+                {% if page_obj.has_next %}
+                    <a href="?page={{ page_obj.next_page_number }}">
+                        <input type="button" value=">"/>
+                    </a>
+                {% endif %}
+                <div>
+                    Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} items
+                </div>
+            </div>
+        {% endif %}
+    </div>
+{% 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 %}
+    <div class="primary-content">
+        <h2>Sent Notifications</h2>
+        <table class="fancy-table" style="margin-top:10px;">
+            <thead>
+                <tr>
+                    <th>ID</th>
+                    <th>Time</th>
+                    <th>Type</th>
+                    <th>Target</th>
+                    <th>Title</th>
+                    <th>Body</th>
+                </tr>
+            </thead>
+            {% for item in page_obj.object_list %}
+                <tr>
+                    <td>{{ item.id }}</td>
+                    <td>{{ item.date_sent }}</td>
+                    <td>{{ item.target }}</td>
+                    <td>
+                        {% if item.target == targets.USER %}
+                            <a href="{% url 'user_profile' user_id=item.user_sent.id %}">{{ item.user_sent }}</a>
+                        {% elif item.target == targets.DEVICE %}
+                            <a href="{% url 'notif_webpush_device_view' model_id=item.id %}">{{ item.device_sent }}</a>
+                        {% elif item.target == targets.DEVICE_QUERYSET %}
+                            <a href="{% url 'notif_webpush_device_view' model_id=item.id %}">View Queryset ({{ item.device_queryset_sent.count }} items)</a>
+                        {% else %}
+                        Unavailable
+                        {% endif %}
+                    </td>
+                    <td>{{ item.title }}</td>
+                    <td>{{ item.body|truncatechars:50 }}</td>
+                </tr>
+            {% endfor %}
+        </table>
+        <div style="text-align:right">
+            <form action="" method="get" style="display:inline;float:right">
+                <input name="page" type="number"
+                min="1" max={{ page_obj.num_pages }} class="dashboard-textinput" style="width: 75px"
+                placeholder={{ page_obj.number }}> of {{ paginator.num_pages }}
+                <input type="submit" value="Go"/>
+                {% if page_obj.has_previous %}
+                    <a href="?page={{ page_obj.previous_page_number }}">
+                        <input type="button" value="<"/>
+                    </a>
+                {% endif %}
+                {% if page_obj.has_next %}
+                    <a href="?page={{ page_obj.next_page_number }}">
+                        <input type="button" value=">"/>
+                    </a>
+                {% endif %}
+                <div>
+                    Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} items
+                </div>
+            </form>
+        </div>
+    </div>
+{% 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 }}
+    <link rel="stylesheet" href="{% static 'vendor/selectize.js-0.12.4/dist/css/selectize.default.css' %}">
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+    <script src="{% static 'vendor/selectize.js-0.12.4/dist/js/standalone/selectize.min.js' %}"></script>
+    <script>
+        $(document).ready(function() {
+            $("select").selectize({
+                plugins: ["remove_button"],
+                placeholder: "Everyone"
+            });
+        })
+    </script>
+{% endblock %}
+
+{% block head %}
+    {% if dark_mode_enabled %}
+        {% stylesheet 'dark/base' %}
+        {% stylesheet 'dark/nav' %}
+    {% endif %}
+{% endblock %}
+
+{% block main %}
+    <div class="primary-content">
+        <h2>Post Push Notification</h2>
+        <form action="" method="POST">
+            {% csrf_token %}
+            <table>
+                {{ form.as_table }}
+            </table>
+            <input type="submit" value="Post" style="width: 365px;">
+        </form>
+    </div>
+{% endblock %}
diff --git a/intranet/templates/preferences/preferences.html b/intranet/templates/preferences/preferences.html
index a5e236c0bff..54e21e48ccb 100644
--- a/intranet/templates/preferences/preferences.html
+++ b/intranet/templates/preferences/preferences.html
@@ -21,44 +21,349 @@
     <script src="{% static 'js/vendor/jquery.formset.js' %}" charset="utf-8"></script>
     <script src="{% static 'vendor/selectize.js-0.12.4/dist/js/standalone/selectize.min.js' %}"></script>
     <script src="{% static 'js/vendor/spin.min.js' %}"></script>
+    <script src="{% static 'js/vendor/js.cookie.min.js' %}"></script>
     <script>
-    $(document).ready(function () {
-        $('#phone-formset-table tr').formset({
-            prefix: '{{ phone_formset.prefix }}',
-            formCssClass: 'phone-formset',
-            deleteText: '<button><i class="fas fa-times"></i></button>',
-            addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
-        });
-        $('#email-formset-table tr').formset({
-            prefix: '{{ email_formset.prefix }}',
-            formCssClass: 'email-formset',
-            deleteText: '<button><i class="fas fa-times"></i></button>',
-            addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
-        });
-        $('#website-formset-table tr').formset({
-            prefix: '{{ website_formset.prefix }}',
-            formCssClass: 'website-formset',
-            deleteText: '<button><i class="fas fa-times"></i></button>',
-            addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
-        });
-        $("#id_primary_email").selectize({
-          "allowEmptyOption": true
-        });
-    })
-    $(function() {
-        $('input.disabled').each(function() {
-            $(this).attr('data-state', $(this).prop('checked'));
-            $(this).click(function(e) {
-                e.preventDefault();
-                var state = $(this).attr('data-state');
-                if (!state) {
-                    $(this).removeProp('checked');
-                } else {
-                    $(this).prop('checked', state);
+        $(document).ready(function () {
+            navigator.serviceWorker.register("serviceworker.js", {
+                    scope: "/"
+            });
+
+            function getAppServerKey() {
+                return new Promise((resolve, reject) => {
+                    $.ajax({
+                        url: "/api/notifications/webpush/application_key",
+                        type: "GET",
+                        dataType: "json",
+                        success: function (response) {
+                            resolve(response["applicationServerKey"]);
+                        },
+                        error: function (xhr, status, error) {
+                            console.error("Error: ", error);
+                            reject(error);
+                        }
+                    })
+                });
+            }
+
+            function getSubscriptionStatus() {
+                return Promise.all([getServerSubStatus(), getClientSubStatus()])
+                    .then(([serverStatus, clientStatus]) => {
+                        return serverStatus.status === true && clientStatus === true;
+                    })
+                    .catch(error => {
+                        // Handle errors
+                        console.error('Error getting subscription status:', error);
+                        return false;
+                    });
+            }
+
+            function getServerSubStatus() {
+                return getEndpoint().then(endpoint => {
+                    return $.ajax({
+                        url: "/api/notifications/webpush/subscription_status",
+                        type: "POST",
+                        dataType: "json",
+                        contentType: "application/json",
+                        data: JSON.stringify({"endpoint": endpoint},),
+                        success: function (response) {
+                            if (response.status === true) {
+                                return true;
+                            }
+                        },
+                        error: function (xhr, status, error) {
+                            sendMessage("error", error.toString());
+                            return Promise.reject(error);
+                        }
+
+                    })
+                })
+            }
+
+            function getClientSubStatus() {
+                return navigator.serviceWorker.ready
+                    .then(function (registration) {
+                        return registration.pushManager.getSubscription();
+                    })
+                    .then(function (subscription) {
+                        return !!subscription;
+                    })
+                    .catch(function (error) {
+                        console.error("Error checking Webpush subscription status:", error);
+                        return false;
+                    });
+            }
+
+            function getEndpoint() {
+                return navigator.serviceWorker.ready
+                    .then(function (registration) {
+                        return registration.pushManager.getSubscription();
+                    })
+                    .then(function (subscription) {
+                        if (!subscription) {
+                            return "null"
+                        }
+                        return subscription.endpoint;
+                    })
+            }
+
+            function registerWebpushWorker() {
+                getAppServerKey().then(appServerKey => {
+                    navigator.serviceWorker.ready.then(function (registration) {
+                        registration.pushManager.subscribe({
+                            userVisibleOnly: true,
+                            applicationServerKey: appServerKey
+                        }).then(function (subscription) {
+                            $.ajax({
+                                url: "/api/notifications/webpush/subscribe",
+                                type: "POST",
+                                contentType: "application/json",
+                                data: JSON.stringify({
+                                    "registration_id": subscription.endpoint,
+                                    "p256dh": btoa(
+                                        String.fromCharCode.apply(
+                                            null, new Uint8Array(subscription.getKey("p256dh"))
+                                        )
+                                    ),
+                                    "auth": btoa(
+                                        String.fromCharCode.apply(
+                                            null, new Uint8Array(subscription.getKey("auth"))
+                                        )
+                                    ),
+                                }),
+                                success: function (response) {
+                                    sendMessage("success", "Subscribed to push notifications on this device");
+                                },
+                                error: function (xhr, status, error) {
+                                    sendMessage("error", error.toString());
+                                }
+                            });
+                        }).catch(function (e) {
+                                sendMessage("error", e.toString());
+                            }
+                        )
+                    })
+
+                });
+            }
+
+            function unsubscribeUser() {
+                navigator.serviceWorker.ready
+                    .then(function (registration) {
+                        return registration.pushManager.getSubscription();
+                    })
+                    .then(function (subscription) {
+                        if (subscription) {
+                            return subscription.unsubscribe()
+                                .then(function () {
+                                    console.log("Successfully unsubscribed from push notifications on the client side.");
+                                });
+                        } else {
+                            console.log("No subscription found.");
+                            return Promise.resolve();
+                        }
+                    })
+                    .catch(function (error) {
+                        console.error("Error unsubscribing from push notifications: ", error);
+                    });
+            }
+
+            function unsubscribeFromServer() {
+                getEndpoint().then(endpoint => {
+                    $.ajax({
+                        url: "/api/notifications/webpush/unsubscribe",
+                        type: "POST",
+                        dataType: "json",
+                        contentType: "application/json",
+                        data: JSON.stringify({"endpoint": endpoint},),
+                        success: function (response) {
+                            if (response.error) {
+                                sendMessage("error", response.error);
+                            }
+                            sendMessage("success", response.message);
+                        },
+                        error: function (xhr, status, error) {
+                            sendMessage("error", error.toString());
+                        }
+
+                    })
+
+                })
+            }
+
+            $('#phone-formset-table tr').formset({
+                prefix: '{{ phone_formset.prefix }}',
+                formCssClass: 'phone-formset',
+                deleteText: '<button><i class="fas fa-times"></i></button>',
+                addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
+            });
+            $('#email-formset-table tr').formset({
+                prefix: '{{ email_formset.prefix }}',
+                formCssClass: 'email-formset',
+                deleteText: '<button><i class="fas fa-times"></i></button>',
+                addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
+            });
+            $('#website-formset-table tr').formset({
+                prefix: '{{ website_formset.prefix }}',
+                formCssClass: 'website-formset',
+                deleteText: '<button><i class="fas fa-times"></i></button>',
+                addText: '<button><i class="fas fa-plus"></i> Add Another</button>'
+            });
+            $("#id_primary_email").selectize({
+                "allowEmptyOption": true
+            });
+
+            var modal = $('#popupForm');
+
+            $('#manage-push').click(function () {
+                modal.show();
+            });
+
+            $('.close-button').click(function () {
+                modal.hide();
+            });
+
+            // When the user clicks anywhere outside the modal, close it
+            $(window).click(function (event) {
+                if ($(event.target).is(modal)) {
+                    modal.hide();
                 }
+            });
+
+            if ("{{ open_push_notif_prefs }}".toLowerCase() === "true") {
+                $('#manage-push').trigger("click");
+            }
+
+            getSubscriptionStatus().then(isSubscribed => {
+                if (isSubscribed) {
+                    $("#sub").prop("value", "Unsubscribe");
+                }
+            })
+
+            $("#sub").on("click", function () {
+                getSubscriptionStatus().then(isSubscribed => {
+                    if (isSubscribed) {
+                        unsubscribeFromServer();
+                        unsubscribeUser();
+                    } else {
+                        registerWebpushWorker();
+                    }
+                })
+            });
+
+            if ("{{ is_ios }}" === "True" && !window.navigator.standalone) {
+                // Prompt the user to install the app via 'add to home screen'
+                // or else webpush won't work
+                $("#sub").on("click", function () {
+                    sendMessage("error",
+                        "You need to add Ion to your home screen to receive push notifications on iOS. " +
+                        "<a href=\'{% url 'ios_notif_setup' %}\' style='color: lightblue;'><strong>Click here for instructions</strong></a>"
+                    );
+                })
+            }
+            else if ("{{ browser_supported }}" === "False") {
+                $("#sub").on("click", function () {
+                    sendMessage("error", "Sorry, but your browser isn't supported at the moment.");
+                })
+            }
+
+            // Replace any get params in the history
+            let url = window.location.href;
+            if(url.includes('?')){
+                const currentUrl = new URL(window.location.href);
+                const urlNoGetParams = new URL(currentUrl.protocol + '//' + currentUrl.host + currentUrl.pathname);
+                history.replaceState({}, '', urlNoGetParams.href);
+            }
+
+            function sendMessage(type, msg) {
+                Cookies.set("enableGetParams", "true");
+                let page = new URL(location.href);
+                page.searchParams.append(type, msg);
+                location.href = page.href;
+            }
+
+            if (Cookies.get("enableGetParams") === "true") {
+                Cookies.set("enableGetParams", "false");
+            }
+
+            $(function () {
+                $('input.disabled').each(function () {
+                    $(this).attr('data-state', $(this).prop('checked'));
+                    $(this).click(function (e) {
+                        e.preventDefault();
+                        var state = $(this).attr('data-state');
+                        if (!state) {
+                            $(this).removeProp('checked');
+                        } else {
+                            $(this).prop('checked', state);
+                        }
+                    })
+                })
             })
+
+            async function getSilentPreference() {
+                return new Promise((resolve, reject) => {
+                    const dbRequest = indexedDB.open("notificationPreferences", 1);
+
+                    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");
+
+                        request.onsuccess = function() {
+                            if (request.result && request.result.silent) {
+                                resolve(request.result.silent);
+                            } else {
+                                resolve(false);
+                            }
+                        };
+
+                        request.onerror = function() {
+                            resolve(false);
+                        };
+                    };
+
+                    dbRequest.onerror = function() {
+                        resolve(false);
+                    };
+                });
+            }
+
+            getSilentPreference().then(function(silent) {
+                $("#silent-notifications").prop("checked", silent)
+            })
+
+            $("#silent-notifications").on("change", function () {
+                let silent = $(this).prop("checked");
+
+                const request = indexedDB.open('notificationPreferences', 1);
+
+                request.onupgradeneeded = function(event) {
+                    const db = event.target.result;
+                    db.createObjectStore('preferences', { keyPath: 'id' });
+                };
+
+                request.onsuccess = function(event) {
+                    const db = event.target.result;
+                    const transaction = db.transaction(['preferences'], 'readwrite');
+                    const store = transaction.objectStore('preferences');
+                    store.put({ id: 'silentNotification', silent: silent });
+                };
+
+            })
+
+            $('.popup-link').hover(function(){
+                $('.popup-content').stop(true, true).fadeIn(200);
+            }, function(){
+                $('.popup-content').stop(true, true).fadeOut(200);
+            });
         })
-    })
+
 
     </script>
 {% endblock %}
@@ -92,39 +397,21 @@ <h3>Bus Route (PM)</h3>
             <br>
             <h3>Notification Options</h3>
             <p>Change how you receive notifications from Intranet.</p>
-            <table class="notification-options">
-              {% if request.user.notificationconfig and request.user.notificationconfig.gcm_token %}
-              <tr>
-                  <td>
-                      <input id="id_receive_push_notifications" name="receive_push_notifications" type="checkbox" {% if not request.user.notificationconfig.gcm_optout %} checked {% endif %}>
-                  </td>
-                  <td>
-                      <label for="id_receive_push_notifications">Receive Push Notifications</label>
-                  </td>
-              </tr>
-              {% else %}
-              <tr>
-                  <td>
-                      <input id="id_receive_push_notifications" type="checkbox" disabled>
-                  </td>
-                  <td>
-                      <label for="id_receive_push_notifications">Receive Push Notifications</label>
-                  </td>
-              </tr>
-              {% endif %}
+            <div>
+                <button id="manage-push" type="button" style="margin-top: 5px;">Manage Push Notifications</button>
                 {% for field in notification_options_form %}
-                <tr>
-                    <td>
-                        {{ field.errors }}
-                        {{ field }}
-                    </td>
-                    <td>
-                        {{ field.label }}
-                    </td>
-                </tr>
+                    <div style="margin-top: 5px;">
+                        {% if field|field_type == "Select" %}
+                            {{ field.label|add:":" }} {{ field }}
+                        {% else %}
+                            {{ field.errors }}
+                            {{ field }}
+                            {{ field.label }}
+                        {% endif %}
+                    </div>
                 {% endfor %}
 
-            </table>
+            </div>
 
             <br>
 
@@ -237,6 +524,48 @@ <h3>Dark Mode</h3>
 
             <button type="submit">Save</button>
             </form>
+        <div id="popupForm" class="modal">
+            <div class="modal-content">
+                <span class="close-button">&times;</span>
+                <h2>Settings</h2>
+                <form action="" method="post">
+                    {% csrf_token %}
+                    {% for field in push_notifications_options_form %}
+                        <div style="margin-top: 5px;">
+                            {{ field.errors }}
+                            {% if field.name == "eighth_waitlist_notifications" and not ENABLE_WAITLIST %}
+                                {{ field.as_hidden }}
+                            {% else %}
+                                {{ field }}
+                                {{ field.label }}
+                                <i class="fa fa-question-circle" id="{{ field.name }}" style="margin-left: 5px;" title="{{ field.help_text }}"></i>
+                            {% endif %}
+                        </div>
+                    {% endfor %}
+                    <hr>
+                    <p style="margin-top: 10px; margin-bottom: 10px; margin-left: 5px; font-size: 15px;">Settings for this device</p>
+                    <input type="checkbox" id="silent-notifications">
+                    <labeL for="silent-notifications" style="margin-left: -4px; margin-top: 10px;">Silence Notifications</labeL>
+                    <i class="fa fa-question-circle" id="silent_notifications" style="margin-left: 5px;" title="Enable silent notifications (notifications won't make sound)"></i>
+                    <hr style="margin-top: 10px;">
+                    <div>
+                        <input type="submit" value="Save" style="margin-top: 10px; display: inline-block;">
+                        <input type="button" value="Subscribe" style="margin-top: 10px; margin-left: 3px; display: inline-block;" id="sub">
+                        <input type="hidden" name="updatepushprefs" value="true">
+                    </div>
+                    <p style="font-size: 12px;">You must click the subscribe button on each device you wish to receive notifications on</p>
+                    <a class="popup-link">Supported browsers
+                        <div class="popup-content">
+                            <p>Chrome (version 42+)</p>
+                            <p>Opera (version 42+)</p>
+                            <p>Firefox (version 44+)</p>
+                            <p>Safari on Mac (version 16+)</p>
+                            <p>Safari on iOS (iOS version 16.4+)</p>
+                        </div>
+                    </a>
+                </form>
+            </div>
+        </div>
 
     </div>
 
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