Skip to content

Commit

Permalink
feat: add push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
aarushtools committed Aug 28, 2024
1 parent 1888e89 commit 848cc66
Show file tree
Hide file tree
Showing 56 changed files with 2,408 additions and 176 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,10 @@ package-lock.json
# Virtual environments
venv/
.venv/

# Webpush
/keys/webpush/
/keys/

# Keys
*.pem
3 changes: 3 additions & 0 deletions config/docker/initial_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions config/scripts/create_vapid_keys.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion cron/eighth-absence.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions docs/sourcedoc/intranet.apps.eighth.management.commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------------------------------------------------

Expand Down
40 changes: 40 additions & 0 deletions docs/sourcedoc/intranet.apps.notifications.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ intranet.apps.notifications package
Submodules
----------

intranet.apps.notifications.api module
--------------------------------------

.. automodule:: intranet.apps.notifications.api
:members:
:undoc-members:
:show-inheritance:

intranet.apps.notifications.emails module
-----------------------------------------

Expand All @@ -12,6 +20,14 @@ intranet.apps.notifications.emails module
:undoc-members:
:show-inheritance:

intranet.apps.notifications.forms module
----------------------------------------

.. automodule:: intranet.apps.notifications.forms
:members:
:undoc-members:
:show-inheritance:

intranet.apps.notifications.models module
-----------------------------------------

Expand All @@ -20,6 +36,14 @@ intranet.apps.notifications.models module
:undoc-members:
:show-inheritance:

intranet.apps.notifications.serializers module
----------------------------------------------

.. automodule:: intranet.apps.notifications.serializers
:members:
:undoc-members:
:show-inheritance:

intranet.apps.notifications.tasks module
----------------------------------------

Expand All @@ -28,6 +52,14 @@ intranet.apps.notifications.tasks module
:undoc-members:
:show-inheritance:

intranet.apps.notifications.tests module
----------------------------------------

.. automodule:: intranet.apps.notifications.tests
:members:
:undoc-members:
:show-inheritance:

intranet.apps.notifications.urls module
---------------------------------------

Expand All @@ -36,6 +68,14 @@ intranet.apps.notifications.urls module
:undoc-members:
:show-inheritance:

intranet.apps.notifications.utils module
----------------------------------------

.. automodule:: intranet.apps.notifications.utils
:members:
:undoc-members:
:show-inheritance:

intranet.apps.notifications.views module
----------------------------------------

Expand Down
8 changes: 8 additions & 0 deletions docs/sourcedoc/intranet.apps.polls.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------

Expand Down
21 changes: 18 additions & 3 deletions intranet/apps/announcements/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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."
Expand Down
34 changes: 32 additions & 2 deletions intranet/apps/announcements/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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]),
},
)
2 changes: 2 additions & 0 deletions intranet/apps/announcements/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
admin_request_announcement_email,
announcement_approved_email,
announcement_posted_email,
announcement_posted_push_notification,
announcement_posted_twitter,
request_announcement_email,
)
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions intranet/apps/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
),
]
8 changes: 8 additions & 0 deletions intranet/apps/bus/consumers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -49,6 +51,12 @@ 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()
logger.error(today.end_datetime)
if today is not None and timezone.now() > today.end_datetime:
# Bus came late
logger.error("bus late")
push_delayed_bus_notifications.delay(route.bus_number)
else:
route.space = ""
route.save()
Expand Down
66 changes: 66 additions & 0 deletions intranet/apps/bus/tasks.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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}
Loading

0 comments on commit 848cc66

Please sign in to comment.