From 852be7ecdb50ef1946afdbd4ac9a82d882b91d51 Mon Sep 17 00:00:00 2001 From: Aarush Chaurasia <2027achauras@tjhsst.edu> Date: Wed, 14 Aug 2024 20:09:49 -0400 Subject: [PATCH] feat: add push notifications --- .gitignore | 7 + config/docker/initial_setup.sh | 3 + config/scripts/create_vapid_keys.py | 25 + cron/eighth-absence.sh | 3 +- .../sourcedoc/intranet.apps.announcements.rst | 96 ++++ docs/sourcedoc/intranet.apps.bus.rst | 8 + ...tranet.apps.eighth.management.commands.rst | 8 + .../sourcedoc/intranet.apps.notifications.rst | 48 ++ docs/sourcedoc/intranet.apps.polls.rst | 8 + intranet/apps/announcements/forms.py | 30 +- intranet/apps/announcements/notifications.py | 34 +- intranet/apps/announcements/views.py | 2 + intranet/apps/api/urls.py | 12 + intranet/apps/bus/consumers.py | 8 + intranet/apps/bus/tasks.py | 117 +++++ intranet/apps/bus/utils.py | 24 + .../management/commands/absence_notify.py | 29 ++ .../migrations/0066_auto_20240725_1929.py | 23 + .../migrations/0071_merge_20241011_1920.py | 14 + intranet/apps/eighth/models.py | 10 +- intranet/apps/eighth/notifications.py | 36 +- intranet/apps/eighth/tasks.py | 130 ++++- intranet/apps/notifications/admin.py | 10 + intranet/apps/notifications/api.py | 78 +++ intranet/apps/notifications/forms.py | 15 + .../0008_userpushnotificationpreferences.py | 29 ++ ...shnotificationpreferences_is_subscribed.py | 18 + ...icationpreferences_silent_notifications.py | 17 + .../migrations/0011_webpushnotification.py | 30 ++ .../migrations/0012_auto_20240730_1928.py | 32 ++ .../migrations/0013_auto_20240818_1950.py | 28 ++ intranet/apps/notifications/models.py | 76 +++ intranet/apps/notifications/serializers.py | 9 + intranet/apps/notifications/tasks.py | 120 +++++ intranet/apps/notifications/tests.py | 243 ++++++++++ intranet/apps/notifications/urls.py | 15 + intranet/apps/notifications/utils.py | 32 ++ intranet/apps/notifications/views.py | 143 +++++- intranet/apps/polls/forms.py | 10 + intranet/apps/polls/notifications.py | 36 ++ intranet/apps/polls/views.py | 4 + intranet/apps/preferences/forms.py | 28 ++ intranet/apps/preferences/views.py | 128 +++-- intranet/apps/schedule/models.py | 5 + intranet/apps/templatetags/dates.py | 11 + intranet/apps/templatetags/forms.py | 5 + intranet/apps/users/models.py | 14 + intranet/settings/__init__.py | 53 ++ intranet/static/css/dark/preferences.scss | 51 ++ intranet/static/css/preferences.scss | 58 +++ .../img/guides/add_to_home_screen_ios.png | Bin 0 -> 83149 bytes intranet/static/serviceworker.js | 146 +++--- intranet/templates/dashboard/admin.html | 3 + .../ios_notifications_guide.html | 51 ++ intranet/templates/notifications/manage.html | 33 ++ .../notifications/webpush_device_info.html | 83 ++++ .../templates/notifications/webpush_list.html | 81 ++++ .../templates/notifications/webpush_post.html | 45 ++ .../notifications/webpush_schedule.html | 83 ++++ .../templates/preferences/preferences.html | 457 +++++++++++++++--- intranet/urls.py | 9 +- requirements.txt | 4 + 62 files changed, 2787 insertions(+), 181 deletions(-) create mode 100644 config/scripts/create_vapid_keys.py create mode 100644 intranet/apps/bus/utils.py create mode 100644 intranet/apps/eighth/management/commands/absence_notify.py create mode 100644 intranet/apps/eighth/migrations/0066_auto_20240725_1929.py create mode 100644 intranet/apps/eighth/migrations/0071_merge_20241011_1920.py create mode 100644 intranet/apps/notifications/admin.py create mode 100644 intranet/apps/notifications/api.py create mode 100644 intranet/apps/notifications/forms.py create mode 100644 intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py create mode 100644 intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py create mode 100644 intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py create mode 100644 intranet/apps/notifications/migrations/0011_webpushnotification.py create mode 100644 intranet/apps/notifications/migrations/0012_auto_20240730_1928.py create mode 100644 intranet/apps/notifications/migrations/0013_auto_20240818_1950.py create mode 100644 intranet/apps/notifications/serializers.py create mode 100644 intranet/apps/notifications/tests.py create mode 100644 intranet/apps/notifications/utils.py create mode 100644 intranet/apps/polls/notifications.py create mode 100644 intranet/static/img/guides/add_to_home_screen_ios.png create mode 100644 intranet/templates/notifications/ios_notifications_guide.html create mode 100644 intranet/templates/notifications/manage.html create mode 100644 intranet/templates/notifications/webpush_device_info.html create mode 100644 intranet/templates/notifications/webpush_list.html create mode 100644 intranet/templates/notifications/webpush_post.html create mode 100644 intranet/templates/notifications/webpush_schedule.html diff --git a/.gitignore b/.gitignore index 18e47145c08..f8181dea159 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,10 @@ package-lock.json # Virtual environments venv/ .venv/ + +# Webpush +/keys/webpush/ +/keys/ + +# Keys +*.pem \ No newline at end of file diff --git a/config/docker/initial_setup.sh b/config/docker/initial_setup.sh index 3786433fc8b..d7aecd1c07d 100755 --- a/config/docker/initial_setup.sh +++ b/config/docker/initial_setup.sh @@ -39,6 +39,9 @@ echo -e "${BLUE}${BOLD}Creating eighth period blocks...${CLEAR}" python3 create_blocks.py -l A B -c 60 python3 create_blocks.py -l A B C -i 4 -c 15 +echo -e "${BLUE}${BOLD}Generating vapid keys...${CLEAR}" +python3 create_vapid_keys.py + echo -e "${BLUE}${BOLD}Cleaning up scripts...${CLEAR}" for file in config/scripts/*.py; do rm "$(basename "$file")" diff --git a/config/scripts/create_vapid_keys.py b/config/scripts/create_vapid_keys.py new file mode 100644 index 00000000000..546efa560db --- /dev/null +++ b/config/scripts/create_vapid_keys.py @@ -0,0 +1,25 @@ +import base64 +import os + +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from py_vapid import Vapid + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.makedirs(os.path.join(PROJECT_ROOT, "keys", "webpush")) + +# Generate VAPID key pair +vapid = Vapid() +vapid.generate_keys() + +# Get public and private keys for the vapid key pair +vapid.save_public_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "public_key.pem")) +public_key_bytes = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + +vapid.save_key(os.path.join(PROJECT_ROOT, "keys", "webpush", "private_key.pem")) + + +# Convert the public key to applicationServerKey format +application_server_key = base64.urlsafe_b64encode(public_key_bytes).replace(b"=", b"").decode("utf8") + +with open(os.path.join(PROJECT_ROOT, "keys", "webpush", "ApplicationServerKey.key"), "w", encoding="utf-8") as f: + f.write(application_server_key) diff --git a/cron/eighth-absence.sh b/cron/eighth-absence.sh index 852e13149e7..4b55b2e7c01 100755 --- a/cron/eighth-absence.sh +++ b/cron/eighth-absence.sh @@ -4,4 +4,5 @@ timestamp=$(date +"%Y-%m-%d-%H%M") cd /usr/local/www/intranet3 ./cron/env.sh ./manage.py absence_email --silent -echo "Absence email sent at $timestamp." >> /var/log/ion/email.log +./cron/env.sh ./manage.py absence_notify --silent +echo "Absence email and push notification sent at $timestamp." >> /var/log/ion/email.log diff --git a/docs/sourcedoc/intranet.apps.announcements.rst b/docs/sourcedoc/intranet.apps.announcements.rst index 1d90c545af2..0e083ac9564 100644 --- a/docs/sourcedoc/intranet.apps.announcements.rst +++ b/docs/sourcedoc/intranet.apps.announcements.rst @@ -76,6 +76,102 @@ intranet.apps.announcements.views module :undoc-members: :show-inheritance: +intranet.apps.announcements.views\_BACKUP\_1087 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BACKUP\_1114 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BACKUP\_1972 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_BACKUP_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1087 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1114 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_BASE\_1972 module +---------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_BASE_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1087 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1114 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_LOCAL\_1972 module +----------------------------------------------------- + +.. automodule:: intranet.apps.announcements.views_LOCAL_1972 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1087 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1087 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1114 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1114 + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.announcements.views\_REMOTE\_1972 module +------------------------------------------------------ + +.. automodule:: intranet.apps.announcements.views_REMOTE_1972 + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/sourcedoc/intranet.apps.bus.rst b/docs/sourcedoc/intranet.apps.bus.rst index 20a4b215591..fdcdf672677 100644 --- a/docs/sourcedoc/intranet.apps.bus.rst +++ b/docs/sourcedoc/intranet.apps.bus.rst @@ -76,6 +76,14 @@ intranet.apps.bus.urls module :undoc-members: :show-inheritance: +intranet.apps.bus.utils module +------------------------------ + +.. automodule:: intranet.apps.bus.utils + :members: + :undoc-members: + :show-inheritance: + intranet.apps.bus.views module ------------------------------ diff --git a/docs/sourcedoc/intranet.apps.eighth.management.commands.rst b/docs/sourcedoc/intranet.apps.eighth.management.commands.rst index 5e20fd893fe..87e2af46314 100644 --- a/docs/sourcedoc/intranet.apps.eighth.management.commands.rst +++ b/docs/sourcedoc/intranet.apps.eighth.management.commands.rst @@ -12,6 +12,14 @@ intranet.apps.eighth.management.commands.absence\_email module :undoc-members: :show-inheritance: +intranet.apps.eighth.management.commands.absence\_notify module +--------------------------------------------------------------- + +.. automodule:: intranet.apps.eighth.management.commands.absence_notify + :members: + :undoc-members: + :show-inheritance: + intranet.apps.eighth.management.commands.delete\_duplicate\_signups module -------------------------------------------------------------------------- diff --git a/docs/sourcedoc/intranet.apps.notifications.rst b/docs/sourcedoc/intranet.apps.notifications.rst index 3f5e527d432..ebbcb95fafa 100644 --- a/docs/sourcedoc/intranet.apps.notifications.rst +++ b/docs/sourcedoc/intranet.apps.notifications.rst @@ -4,6 +4,22 @@ intranet.apps.notifications package Submodules ---------- +intranet.apps.notifications.admin module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.admin + :members: + :undoc-members: + :show-inheritance: + +intranet.apps.notifications.api module +-------------------------------------- + +.. automodule:: intranet.apps.notifications.api + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.emails module ----------------------------------------- @@ -12,6 +28,14 @@ intranet.apps.notifications.emails module :undoc-members: :show-inheritance: +intranet.apps.notifications.forms module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.forms + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.models module ----------------------------------------- @@ -20,6 +44,14 @@ intranet.apps.notifications.models module :undoc-members: :show-inheritance: +intranet.apps.notifications.serializers module +---------------------------------------------- + +.. automodule:: intranet.apps.notifications.serializers + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.tasks module ---------------------------------------- @@ -28,6 +60,14 @@ intranet.apps.notifications.tasks module :undoc-members: :show-inheritance: +intranet.apps.notifications.tests module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.tests + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.urls module --------------------------------------- @@ -36,6 +76,14 @@ intranet.apps.notifications.urls module :undoc-members: :show-inheritance: +intranet.apps.notifications.utils module +---------------------------------------- + +.. automodule:: intranet.apps.notifications.utils + :members: + :undoc-members: + :show-inheritance: + intranet.apps.notifications.views module ---------------------------------------- diff --git a/docs/sourcedoc/intranet.apps.polls.rst b/docs/sourcedoc/intranet.apps.polls.rst index d5e430efb60..6854b5ff000 100644 --- a/docs/sourcedoc/intranet.apps.polls.rst +++ b/docs/sourcedoc/intranet.apps.polls.rst @@ -28,6 +28,14 @@ intranet.apps.polls.models module :undoc-members: :show-inheritance: +intranet.apps.polls.notifications module +---------------------------------------- + +.. automodule:: intranet.apps.polls.notifications + :members: + :undoc-members: + :show-inheritance: + intranet.apps.polls.tests module -------------------------------- diff --git a/intranet/apps/announcements/forms.py b/intranet/apps/announcements/forms.py index 43684b8b1fc..486a1771328 100644 --- a/intranet/apps/announcements/forms.py +++ b/intranet/apps/announcements/forms.py @@ -23,7 +23,12 @@ class Meta: fields = ["title", "author", "content", "groups", "expiration_date", "notify_post", "notify_email_all", "update_added_date", "pinned"] help_texts = { "expiration_date": "By default, announcements expire after two weeks. Choose the shortest time necessary.", - "notify_post": "If this box is checked, students who have signed up for notifications will receive an email.", + "notify_post": ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will receive a " + "push notification." + ), "notify_email_all": ( "This will send an email notification to all of the users who can see this post. " "This option does NOT take users' email notification preferences into account, so please use with care." @@ -50,9 +55,12 @@ def __init__(self, user, *args, **kwargs): else: self.fields["activity"].queryset = [] self.fields["activity"].required = True - self.fields[ - "notify_post" - ].help_text = "If this box is checked, students who have subscribed to your club's announcements will receive an email." + self.fields["notify_post"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will receive a " + "push notification." + ) if "instance" in kwargs: # Don't allow changing the activity once the announcement has been created self.fields["activity"].widget.attrs["disabled"] = True @@ -92,7 +100,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["expiration_date"].help_text = "By default, announcements expire after two weeks. Choose the shortest time necessary." - self.fields["notify_post_resend"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + self.fields["notify_post_resend"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will " + "receive a push notification." + ) self.fields["notify_email_all_resend"].help_text = ( "This will resend an email notification to all of the users who can see this post. This option " @@ -153,7 +166,12 @@ class AnnouncementAdminForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["notify_post"].help_text = "If this box is checked, students who have signed up for notifications will receive an email." + self.fields["notify_post"].help_text = ( + "If this box is checked, students who have signed up for email " + "notifications will receive an email " + "and those who have signed up for push notifications will receive a " + "push notification." + ) self.fields["notify_email_all"].help_text = ( "This will send an email notification to all of the users who can see this post. This option " "does NOT take users' email notification preferences into account, so please use with care." diff --git a/intranet/apps/announcements/notifications.py b/intranet/apps/announcements/notifications.py index 05a8da436bf..a019ff46a82 100644 --- a/intranet/apps/announcements/notifications.py +++ b/intranet/apps/announcements/notifications.py @@ -7,12 +7,18 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.core import exceptions +from django.db.models import Q from django.urls import reverse +from django.utils.html import strip_tags +from push_notifications.models import WebPushDevice from requests_oauthlib import OAuth1 from sentry_sdk import capture_exception from ...utils.date import get_senior_graduation_year -from ..notifications.tasks import email_send_task +from ..notifications.tasks import email_send_task, send_bulk_notification +from ..notifications.utils import truncate_content, truncate_title +from ..users.models import User +from .models import Announcement logger = logging.getLogger(__name__) @@ -148,7 +154,7 @@ def announcement_posted_email(request, obj, send_all=False): emails.append(u.notification_email) users_send.append(u) - if not settings.PRODUCTION and len(emails) > 3: + if not settings.PRODUCTION and len(emails) > 3 and not settings.FORCE_EMAIL_SEND: raise exceptions.PermissionDenied("You're about to email a lot of people, and you aren't in production!") base_url = request.build_absolute_uri(reverse("index")) @@ -216,3 +222,27 @@ def notify_twitter(status): req = requests.post(url, data=data, auth=auth, timeout=15) return req.text + + +def announcement_posted_push_notification(obj: Announcement) -> None: + """Send a (Web)push notification to users when an announcement is posted. + + obj: The announcement object + + """ + + if not obj.groups.all(): + users = User.objects.filter(push_notification_preferences__announcement_notifications=True) + devices = WebPushDevice.objects.filter(user__in=users) + else: + users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__announcement_notifications=True)) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"Announcement: {truncate_title(obj.title)} ({obj.get_author()})", + body=truncate_content(strip_tags(obj.content_no_links)), + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("view_announcement", args=[obj.id]), + }, + ) diff --git a/intranet/apps/announcements/views.py b/intranet/apps/announcements/views.py index 4412ac0299b..b2a1346c685 100644 --- a/intranet/apps/announcements/views.py +++ b/intranet/apps/announcements/views.py @@ -26,6 +26,7 @@ admin_request_announcement_email, announcement_approved_email, announcement_posted_email, + announcement_posted_push_notification, announcement_posted_twitter, request_announcement_email, ) @@ -62,6 +63,7 @@ def announcement_posted_hook(request, obj): """ if obj.notify_post: announcement_posted_twitter(request, obj) + announcement_posted_push_notification(obj) try: notify_all = obj.notify_email_all except AttributeError: diff --git a/intranet/apps/api/urls.py b/intranet/apps/api/urls.py index 2c14c24eb07..2daca45a4ec 100644 --- a/intranet/apps/api/urls.py +++ b/intranet/apps/api/urls.py @@ -4,6 +4,7 @@ from ..bus import api as bus_api from ..eighth.views import api as eighth_api from ..emerg import api as emerg_api +from ..notifications import api as notification_api from ..schedule import api as schedule_api from ..users import api as users_api from .views import api_root @@ -43,4 +44,15 @@ re_path(r"^/emerg$", emerg_api.emerg_status, name="api_emerg_status"), re_path(r"^/bus$", bus_api.RouteList.as_view(), name="api_bus_list"), re_path(r"^/bus/(?P\d+)$", bus_api.RouteDetail.as_view(), name="api_bus_detail"), + re_path( + r"^/notifications/webpush/application_key$", notification_api.GetApplicationServerKey.as_view(), name="api_get_vapid_application_server_key" + ), + re_path(r"^/notifications/webpush/subscribe$", notification_api.WebpushSubscribeDevice.as_view(), name="api_webpush_subscribe"), + re_path(r"^/notifications/webpush/unsubscribe$", notification_api.WebpushUnsubscribeDevice.as_view(), name="api_webpush_unsubscribe"), + re_path(r"^/notifications/webpush/update_subscription$", notification_api.WebpushUpdateDevice.as_view(), name="api_webpush_update_subscription"), + re_path( + r"^/notifications/webpush/subscription_status$", + notification_api.GetWebpushSubscriptionStatus.as_view(), + name="api_webpush_subscription_status", + ), ] diff --git a/intranet/apps/bus/consumers.py b/intranet/apps/bus/consumers.py index fe8617f2711..e3bcf33f1fb 100644 --- a/intranet/apps/bus/consumers.py +++ b/intranet/apps/bus/consumers.py @@ -5,7 +5,9 @@ from django.conf import settings from django.utils import timezone +from ..schedule.models import Day from .models import BusAnnouncement, Route +from .tasks import push_bus_announcement_notifications, push_delayed_bus_notifications logger = logging.getLogger(__name__) @@ -43,12 +45,18 @@ def receive_json(self, content): # pylint: disable=arguments-differ content["announcement"] = "" announcement = BusAnnouncement.object() announcement.message = content["announcement"] + push_bus_announcement_notifications.delay(announcement.message) announcement.save() else: route = Route.objects.get(id=content["id"]) route.status = content["status"] if content["time"] == "afternoon" and route.status == "a": route.space = content["space"] + today = Day.objects.today() + + if today is not None and timezone.now() > today.end_datetime: + # Bus came late + push_delayed_bus_notifications.delay(route.bus_number) else: route.space = "" route.save() diff --git a/intranet/apps/bus/tasks.py b/intranet/apps/bus/tasks.py index 2dc575783b8..33b0f013098 100644 --- a/intranet/apps/bus/tasks.py +++ b/intranet/apps/bus/tasks.py @@ -1,7 +1,18 @@ +import datetime +from typing import Union + from celery import shared_task from celery.utils.log import get_task_logger +from django.db.models import Q +from django.urls import reverse +from push_notifications.models import WebPushDevice +from ... import settings +from ..notifications.tasks import send_bulk_notification, send_notification_to_user +from ..schedule.models import Day +from ..users.models import User from .models import Route +from .utils import extract_bus_number logger = get_task_logger(__name__) @@ -12,3 +23,109 @@ def reset_routes() -> None: for route in Route.objects.all(): route.reset_status() + + +@shared_task +def push_bus_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification to each user of their bus location + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + + if schedule: + day = Day.objects.today() + if day is not None: + if return_result: + return day.end_datetime + + push_bus_notifications.apply_async(eta=day.end_datetime) + logger.info("Push bus notifications scheduled at %s (bus info)", str(day.end_datetime)) + else: + route_translations = {key: convert_dataset(value) for key, value in settings.PUSH_ROUTE_TRANSLATIONS.items()} + + users = User.objects.filter(push_notification_preferences__bus_notifications=True) + + for user in users: + if user.bus_route.status == "d": + send_notification_to_user.delay( + user=user, + title="Bus Delayed", + body=f"Sorry, your bus ({user.bus_route.bus_number}) has been delayed.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + else: + space = user.bus_route.space + notif_sent = False + + if space is not None: + for key, value in route_translations.items(): + if space in value: + send_notification_to_user.delay( + user=user, + title="Bus Location", + body=f"Your bus is at the {key} of the parking lot.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + notif_sent = True + if not notif_sent: + send_notification_to_user.delay( + user=user, + title="Bus not here", + body=f"Hmm, seems like your bus ({user.bus_route.bus_number}) isn't here yet. " + f"Check bus announcements for more information.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + return None + + +@shared_task +def push_delayed_bus_notifications(bus_number: str) -> None: + users = User.objects.filter(Q(push_notification_preferences__bus_notifications=True) & Q(bus_route__bus_number=bus_number)) + + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title="Bus Arrived", + body="Your delayed bus just arrived.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + +@shared_task +def push_bus_announcement_notifications(message: str) -> None: + bus_num = extract_bus_number(message) + if bus_num: + users = User.objects.filter(bus_route__route_name__contains=bus_num) + logger.error(bus_num) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"Bus Announcement (bus {bus_num})", + body=message, + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("bus"), + }, + ) + + +def convert_dataset(dataset): + # Convert each number to the format "_number" and return as a set + # because that's how the ID spots are named + return {"_" + str(number) for number in dataset} diff --git a/intranet/apps/bus/utils.py b/intranet/apps/bus/utils.py new file mode 100644 index 00000000000..8377094f38c --- /dev/null +++ b/intranet/apps/bus/utils.py @@ -0,0 +1,24 @@ +import re +from typing import Union + + +def extract_bus_number(message: str) -> Union[str, None]: + """Returns number only i.e. + jt-100 -> 100 + JT100 -> 100 + 100 -> 100 + pc200 -> 200 + returns None if not found + """ + + replace = ["-", "jt", "ac", "lc", "pw"] + + match = re.search(r"\b(jt|ac|pw|lc)?-?(\d+)\b", message, re.IGNORECASE) + + if match: + matched_str = match.group(0) + for query in replace: + matched_str = matched_str.lower().replace(query, "") + return matched_str + + return None diff --git a/intranet/apps/eighth/management/commands/absence_notify.py b/intranet/apps/eighth/management/commands/absence_notify.py new file mode 100644 index 00000000000..8c2b441863e --- /dev/null +++ b/intranet/apps/eighth/management/commands/absence_notify.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand + +from intranet.apps.eighth.models import EighthSignup +from intranet.apps.eighth.notifications import absence_notification + + +class Command(BaseCommand): + help = "Push notify users who have an Eighth Period absence (via Webpush.)" + + def add_arguments(self, parser): + parser.add_argument("--silent", action="store_true", dest="silent", default=False, help="Be silent.") + + parser.add_argument("--pretend", action="store_true", dest="pretend", default=False, help="Pretend, and don't actually do anything.") + + def handle(self, *args, **options): + log = not options["silent"] + + absences = EighthSignup.objects.get_absences().filter(absence_notified=False) + + for signup in absences: + if log: + self.stdout.write(str(signup)) + if not options["pretend"]: + absence_notification(signup) + signup.absence_notified = True + signup.save() + + if log: + self.stdout.write("Done.") diff --git a/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py new file mode 100644 index 00000000000..c4d02fdd164 --- /dev/null +++ b/intranet/apps/eighth/migrations/0066_auto_20240725_1929.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-07-25 23:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0065_auto_20220903_0038'), + ] + + operations = [ + migrations.AddField( + model_name='eighthsignup', + name='absence_notified', + field=models.BooleanField(blank=True, default=False), + ), + migrations.AddField( + model_name='historicaleighthsignup', + name='absence_notified', + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/intranet/apps/eighth/migrations/0071_merge_20241011_1920.py b/intranet/apps/eighth/migrations/0071_merge_20241011_1920.py new file mode 100644 index 00000000000..4fa8b28668d --- /dev/null +++ b/intranet/apps/eighth/migrations/0071_merge_20241011_1920.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.25 on 2024-10-11 23:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('eighth', '0066_auto_20240725_1929'), + ('eighth', '0070_eighthactivity_club_sponsors'), + ] + + operations = [ + ] diff --git a/intranet/apps/eighth/models.py b/intranet/apps/eighth/models.py index 264aba5386e..887a5bc2f78 100644 --- a/intranet/apps/eighth/models.py +++ b/intranet/apps/eighth/models.py @@ -1512,9 +1512,14 @@ def cancel(self): self.save(update_fields=["cancelled"]) if not self.is_both_blocks or self.block.block_letter != "B": - from .notifications import activity_cancelled_email # pylint: disable=import-outside-toplevel,cyclic-import + # pylint: disable=import-outside-toplevel,cyclic-import + from .notifications import ( + activity_cancelled_email, + activity_cancelled_notification, + ) activity_cancelled_email(self) + activity_cancelled_notification(self) def uncancel(self): """Uncancel an EighthScheduledActivity. @@ -1608,6 +1613,8 @@ class EighthSignup(AbstractBaseEighthModel): Whether the student has dismissed the absence notification. absence_emailed Whether the student has been emailed about the absence. + absence_notified + Whether the student has received a push notification about their absence """ objects = EighthSignupManager() @@ -1628,6 +1635,7 @@ class EighthSignup(AbstractBaseEighthModel): was_absent = models.BooleanField(default=False, blank=True) absence_acknowledged = models.BooleanField(default=False, blank=True) absence_emailed = models.BooleanField(default=False, blank=True) + absence_notified = models.BooleanField(default=False, blank=True) archived_was_absent = models.BooleanField(default=False, blank=True) diff --git a/intranet/apps/eighth/notifications.py b/intranet/apps/eighth/notifications.py index 738c475a039..6ff02fd5422 100644 --- a/intranet/apps/eighth/notifications.py +++ b/intranet/apps/eighth/notifications.py @@ -1,9 +1,11 @@ import logging from django.urls import reverse +from push_notifications.models import WebPushDevice +from ... import settings from ..notifications.emails import email_send -from ..notifications.tasks import email_send_task +from ..notifications.tasks import email_send_task, send_bulk_notification, send_notification_to_user from .models import EighthScheduledActivity, EighthSignup logger = logging.getLogger(__name__) @@ -69,7 +71,7 @@ def activity_cancelled_email(sched_act: EighthScheduledActivity): emails = list({signup.user.notification_email for signup in sched_act.eighthsignup_set.filter(user__receive_eighth_emails=True)}) - base_url = "https://ion.tjhsst.edu" + base_url = settings.PUSH_NOTIFICATIONS_BASE_URL data = {"sched_act": sched_act, "date_str": date_str, "base_url": base_url} @@ -93,7 +95,7 @@ def absence_email(signup, use_celery=True): # We can't build an absolute URI because this isn't being executed # in the context of a Django request - base_url = "https://ion.tjhsst.edu" # request.build_absolute_uri(reverse('index')) + base_url = settings.PUSH_NOTIFICATIONS_BASE_URL # request.build_absolute_uri(reverse('index')) data = { "user": user, @@ -109,3 +111,31 @@ def absence_email(signup, use_celery=True): return None else: return email_send(*args) + + +def activity_cancelled_notification(sched_act: EighthScheduledActivity): + date_str = sched_act.block.date.strftime("%A, %B %-d") + devices = WebPushDevice.objects.filter(user__in=sched_act.members.all()) + + send_bulk_notification.delay( + filtered_objects=devices, + title="Eighth Period Activity Cancelled", + body=f"The activity '{sched_act.activity.name}' was cancelled on {date_str} " + f"for {sched_act.block.block_letter}. You will need to select a new activity", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[sched_act.block.id]), + }, + ) + + +def absence_notification(signup: EighthSignup): + user = signup.user + if user.push_notification_preferences.is_subscribed: + send_notification_to_user.delay( + user=signup.user, + title="Eighth Period Absence", + body=f"You received an Eighth Period absence on {signup.scheduled_activity.block}", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_absences"), + }, + ) diff --git a/intranet/apps/eighth/tasks.py b/intranet/apps/eighth/tasks.py index 20d4326e92c..62ed1f8d9ef 100644 --- a/intranet/apps/eighth/tasks.py +++ b/intranet/apps/eighth/tasks.py @@ -1,18 +1,23 @@ import calendar import datetime -from typing import Collection +from typing import Any, Collection, List, Union from celery import shared_task from celery.utils.log import get_task_logger from django.conf import settings from django.contrib.auth import get_user_model from django.core.mail import EmailMessage +from django.urls import reverse from django.utils import timezone +from push_notifications.models import WebPushDevice from ...utils.helpers import join_nicely from ..groups.models import Group from ..notifications.emails import email_send -from .models import EighthActivity, EighthRoom, EighthScheduledActivity +from ..notifications.tasks import send_bulk_notification, send_notification_to_user +from ..schedule.models import Day +from ..users.models import User +from .models import EighthActivity, EighthBlock, EighthRoom, EighthScheduledActivity logger = get_task_logger(__name__) @@ -343,3 +348,124 @@ def follow_up_absence_emails(): [student.notification_email], bcc=True, ) + + +@shared_task +def push_eighth_reminder_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification reminders to sign up, specified number of minutes prior to blocks locking + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + if schedule: + block = EighthBlock.objects.get_blocks_today().first() + + if block is not None: + # Get the time to send reminder notifications (PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES + # minutes prior to the block locking) + block_datetime = datetime.datetime.combine(timezone.now(), block.signup_time) + block_datetime = timezone.make_aware(block_datetime, timezone.get_current_timezone()) + notification_datetime = block_datetime - datetime.timedelta(minutes=settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES) + + if return_result: + return notification_datetime + + push_eighth_reminder_notifications.apply_async(eta=notification_datetime) + logger.info("Push reminder notifications scheduled at %s for %s block (eighth reminder)", str(notification_datetime), block.block_letter) + + else: + todays_blocks = EighthBlock.objects.get_blocks_today() + + if todays_blocks is not None: + for block in todays_blocks: + unsigned_students = block.get_unsigned_students() + + # We only want to send this notification to users who have enabled "eighth_reminder_notifications" + # in their preferences. + users_to_send = unsigned_students.filter(push_notification_preferences__eighth_reminder_notifications=True) + + # No need to check if the user is subscribed since we are passing WebPushDevice objects directly + devices_to_send = WebPushDevice.objects.filter(user__in=users_to_send) + + send_bulk_notification( + filtered_objects=devices_to_send, + title="Sign up for Eighth Period", + body=f"You have not signed up for today's eighth period ({block.block_letter} block). " + f"Sign ups close in {settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} minutes.", + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_signup", args=[block.id]), + }, + ) + return None + + +@shared_task +def push_glance_notifications(schedule: bool = False, return_result: bool = False) -> Union[None, datetime.datetime]: + """Send push notification to each user containing their 'glance' + + Args: + schedule: Schedule for a future run instead of instantly running + return_result: when true, return the result instead of scheduling. no effect if schedule is false + + Returns: + None, or the datetime indicating when the task is scheduled if return_result is true + """ + if schedule: + today = Day.objects.today() + if today: + today_8 = today.day_type.blocks.filter(name__contains="8") + if today_8: + timezone_now = timezone.now().today() + first_start_time = datetime.time(today_8[0].start.hour, today_8[0].start.minute) + first_start_date = datetime.datetime.combine(timezone_now, first_start_time) - datetime.timedelta(minutes=10) + aware_first_start_date = timezone.make_aware(first_start_date, timezone.get_current_timezone()) + + if return_result: + return aware_first_start_date + + push_glance_notifications.apply_async(eta=first_start_date) + logger.info("Push glance notifications scheduled at %s (glance)", str(first_start_date)) + else: + users_to_send = User.objects.filter(push_notification_preferences__glance_notifications=True) + blocks = EighthBlock.objects.get_blocks_today() + + if blocks: + for user in users_to_send: + sch_acts = [] + for b in blocks: + try: + act = user.eighthscheduledactivity_set.get(block=b) + if act.activity.name != "z - Hybrid Sticky": + sch_acts.append( + [b, act, ", ".join([r.name for r in act.get_true_rooms()]), ", ".join([s.name for s in act.get_true_sponsors()])] + ) + except EighthScheduledActivity.DoesNotExist: + sch_acts.append([b, None]) + + body = "\n".join( + [ + f"{s[0].hybrid_text if list_index_exists(0, s) else None} block: " + f"{s[1].full_title if list_index_exists(1, s) else None} " + f"(Room {s[2] if list_index_exists(2, s) else None})" + for s in sch_acts + ] + ) + + send_notification_to_user( + user=user, + title="Eighth Period Glance", + body=body, + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("eighth_location"), + }, + ) + + return None + + +def list_index_exists(index: int, list_to_check: List[Any]) -> bool: + return len(list_to_check) > index and list_to_check[index] diff --git a/intranet/apps/notifications/admin.py b/intranet/apps/notifications/admin.py new file mode 100644 index 00000000000..ba152d3b23f --- /dev/null +++ b/intranet/apps/notifications/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from intranet.apps.notifications.models import WebPushNotification + + +class WebPushNotificationAdmin(admin.ModelAdmin): + search_fields = ["title", "user_sent__username", "target"] + + +admin.site.register(WebPushNotification, WebPushNotificationAdmin) diff --git a/intranet/apps/notifications/api.py b/intranet/apps/notifications/api.py new file mode 100644 index 00000000000..2a624b6d671 --- /dev/null +++ b/intranet/apps/notifications/api.py @@ -0,0 +1,78 @@ +import os + +from push_notifications.models import WebPushDevice +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from intranet.apps.notifications.serializers import WebPushDeviceSerializer + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + + +class GetApplicationServerKey(APIView): + def get(self, request): + # Load the VAPID application server key from a file + file_path = os.path.join(PROJECT_ROOT, "keys", "webpush", "ApplicationServerKey.key") + with open(file_path, encoding="utf-8") as file: + server_key = file.read().strip() + return Response({"applicationServerKey": server_key}, status=status.HTTP_200_OK) + + +class GetWebpushSubscriptionStatus(APIView): + def post(self, request): + endpoint = request.data.get("endpoint") + try: + subscription = WebPushDevice.objects.filter(registration_id=endpoint).first() + except WebPushDevice.DoesNotExist: + return Response({"status": False}, status=status.HTTP_200_OK) + if subscription is not None and subscription.active: + return Response({"status": True}, status=status.HTTP_200_OK) + else: + return Response({"status": False}, status=status.HTTP_200_OK) + + +class WebpushSubscribeDevice(generics.CreateAPIView): + queryset = WebPushDevice.objects.all() + serializer_class = WebPushDeviceSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class WebpushUpdateDevice(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + old_registration_id = request.data.get("old_registration_id") + + try: + subscription = WebPushDevice.objects.filter(registration_id=old_registration_id).first() + subscription.registration_id = request.data.get("registration_id") + subscription.p256dh = request.data.get("p256dh") + subscription.auth = request.data.get("auth") + subscription.save() + + return Response({"message": "Subscription updated"}, status=status.HTTP_200_OK) + except WebPushDevice.DoesNotExist: + return Response({"error": "Subscription not found"}, status=status.HTTP_404_NOT_FOUND) + + +class WebpushUnsubscribeDevice(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + endpoint = request.data.get("endpoint") + + try: + subscription = WebPushDevice.objects.filter(registration_id=endpoint).first() + subscription.delete() + # Check if the user no longer has any (0) subscribed devices left + if WebPushDevice.objects.filter(user=request.user).count() == 0: + request.user.push_notification_preferences.is_subscribed = False + else: + request.user.push_notification_preferences.is_subscribed = True + return Response({"message": "Subscription deleted"}, status=status.HTTP_200_OK) + except WebPushDevice.DoesNotExist: + return Response({"error": "Subscription not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/intranet/apps/notifications/forms.py b/intranet/apps/notifications/forms.py new file mode 100644 index 00000000000..eed15500cd1 --- /dev/null +++ b/intranet/apps/notifications/forms.py @@ -0,0 +1,15 @@ +from django import forms + +from intranet.apps.groups.models import Group +from intranet.apps.users.models import User + + +class SendPushNotificationForm(forms.Form): + title = forms.CharField(max_length=50) + body = forms.CharField( + max_length=200, + widget=forms.Textarea(attrs={"rows": 4, "cols": 40}), + ) + url = forms.URLField(initial="https://ion.tjhsst.edu") + users = forms.ModelMultipleChoiceField(queryset=User.objects.all(), required=False) + groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), required=False) diff --git a/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py b/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py new file mode 100644 index 00000000000..f7d75a54ad7 --- /dev/null +++ b/intranet/apps/notifications/migrations/0008_userpushnotificationpreferences.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.25 on 2024-07-24 17:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0007_auto_20151221_2259'), + ] + + operations = [ + migrations.CreateModel( + name='UserPushNotificationPreferences', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('eighth_reminder_notifications', models.BooleanField(default=True, verbose_name='Eighth Period Reminder Notifications')), + ('eighth_waitlist_notifications', models.BooleanField(default=False, verbose_name='Eighth Period Waitlist Notifications')), + ('glance_notifications', models.BooleanField(default=False, verbose_name='Eighth Period Glance Notification')), + ('announcement_notifications', models.BooleanField(default=True, verbose_name='Announcement Notifications')), + ('poll_notifications', models.BooleanField(default=True, verbose_name='Poll Notifications')), + ('silent_notifications', models.BooleanField(default=False, verbose_name='Silent')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='push_notification_preferences', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py b/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py new file mode 100644 index 00000000000..02faa1c15f9 --- /dev/null +++ b/intranet/apps/notifications/migrations/0009_userpushnotificationpreferences_is_subscribed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2024-07-25 13:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0008_userpushnotificationpreferences'), + ] + + operations = [ + migrations.AddField( + model_name='userpushnotificationpreferences', + name='is_subscribed', + field=models.BooleanField(default=False), + ), + ] diff --git a/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py b/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py new file mode 100644 index 00000000000..5cf0c7f2e65 --- /dev/null +++ b/intranet/apps/notifications/migrations/0010_remove_userpushnotificationpreferences_silent_notifications.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2024-07-25 23:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0009_userpushnotificationpreferences_is_subscribed'), + ] + + operations = [ + migrations.RemoveField( + model_name='userpushnotificationpreferences', + name='silent_notifications', + ), + ] diff --git a/intranet/apps/notifications/migrations/0011_webpushnotification.py b/intranet/apps/notifications/migrations/0011_webpushnotification.py new file mode 100644 index 00000000000..cbbf1283441 --- /dev/null +++ b/intranet/apps/notifications/migrations/0011_webpushnotification.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2024-07-27 23:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('push_notifications', '0010_alter_gcmdevice_options_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0010_remove_userpushnotificationpreferences_silent_notifications'), + ] + + operations = [ + migrations.CreateModel( + name='WebPushNotification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_sent', models.DateTimeField(auto_now=True)), + ('target', models.CharField(choices=[('user', 'User'), ('device', 'Single Device'), ('device_queryset', 'Device Queryset (Multiple Devices)')], max_length=15)), + ('title', models.TextField()), + ('body', models.TextField()), + ('device_queryset_sent', models.ManyToManyField(blank=True, to='push_notifications.WebPushDevice')), + ('device_sent', models.ForeignKey(blank=True, default='Deleted Device', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='notifications_device_sent', to='push_notifications.webpushdevice')), + ('user_sent', models.ForeignKey(blank=True, default='Deleted User', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='notifications_user_sent', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py b/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py new file mode 100644 index 00000000000..fb15f77f13f --- /dev/null +++ b/intranet/apps/notifications/migrations/0012_auto_20240730_1928.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2024-07-30 23:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('push_notifications', '0010_alter_gcmdevice_options_and_more'), + ('notifications', '0011_webpushnotification'), + ] + + operations = [ + migrations.AddField( + model_name='userpushnotificationpreferences', + name='bus_notifications', + field=models.BooleanField(default=False, verbose_name='Bus Notifications'), + ), + migrations.AlterField( + model_name='webpushnotification', + name='device_sent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications_device_sent', to='push_notifications.webpushdevice'), + ), + migrations.AlterField( + model_name='webpushnotification', + name='user_sent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications_user_sent', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py b/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py new file mode 100644 index 00000000000..f5b1c025e98 --- /dev/null +++ b/intranet/apps/notifications/migrations/0013_auto_20240818_1950.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.25 on 2024-08-18 23:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0012_auto_20240730_1928'), + ] + + operations = [ + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='bus_notifications', + field=models.BooleanField(default=True, verbose_name='Bus Notifications'), + ), + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='eighth_waitlist_notifications', + field=models.BooleanField(default=True, verbose_name='Eighth Period Waitlist Notifications'), + ), + migrations.AlterField( + model_name='userpushnotificationpreferences', + name='glance_notifications', + field=models.BooleanField(default=True, verbose_name='Eighth Period Glance Notification'), + ), + ] diff --git a/intranet/apps/notifications/models.py b/intranet/apps/notifications/models.py index b5251ff2841..32284780188 100644 --- a/intranet/apps/notifications/models.py +++ b/intranet/apps/notifications/models.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import models +from push_notifications.models import WebPushDevice class NotificationConfig(models.Model): @@ -39,3 +40,78 @@ def data(self): if json_data and "data" in json_data: return json_data["data"] return {} + + +class UserPushNotificationPreferences(models.Model): + """Represents a user's preferences for (Web)push notifications + By default, subscribing to notifications enrolls the user for + eighth absence and scheduling conflict (i.e. cancelled activity) notifications. + Attributes: + user + The :class:`User` who has + subscribed to notifications. + eighth_reminder_notifications + Whether the user wants to receive eighth period reminder + notifications to sign up if they haven't already + signed up within settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES + minutes of the blocks locking + eighth_waitlist_notifications + Whether the user wants to receive notifications if using the + waitlist. This is currently not in use (waitlist is disabled) + glance_notifications + Whether the user wants to receive their eighth period "glance" + as a notification (it shows what blocks they've signed up for) + announcement_notifications + Whether the user wants to receive notifications when a new + Ion announcement is posted + poll_notifications + Whether the user wants to receive a notification when a poll + they can vote in opens + bus_notifications + Whether the user wants to receive notifications related to bus info + i.e. when and where their bus arrives or if their bus is late + is_subscribed + Set to true if the user has one or more devices subscribed to Webpush; + otherwise, false. + """ + + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="push_notification_preferences", on_delete=models.CASCADE) + eighth_reminder_notifications = models.BooleanField("Eighth Period Reminder Notifications", default=True) + eighth_waitlist_notifications = models.BooleanField("Eighth Period Waitlist Notifications", default=True) + glance_notifications = models.BooleanField("Eighth Period Glance Notification", default=True) + announcement_notifications = models.BooleanField("Announcement Notifications", default=True) + poll_notifications = models.BooleanField("Poll Notifications", default=True) + bus_notifications = models.BooleanField("Bus Notifications", default=True) + + # True if the user is subscribed to at least one device or more + is_subscribed = models.BooleanField(default=False) + + def __str__(self): + return str(self.user) + + +class WebPushNotification(models.Model): + """This model is only used to store sent WebPushNotifications. + If you are trying to send a notification, using the send notification + functions located in intranet.apps.notifications.tasks + Notifications sent from those functions are automatically added here + to keep track of sent notifications' history + """ + + class Targets(models.TextChoices): + USER = "user", "User" + DEVICE = "device", "Single Device" + DEVICE_QUERYSET = "device_queryset", "Device Queryset (Multiple Devices)" + + date_sent = models.DateTimeField(auto_now=True) + target = models.CharField(max_length=15, choices=Targets.choices) + + user_sent = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications_user_sent") + device_queryset_sent = models.ManyToManyField(WebPushDevice, blank=True) + device_sent = models.ForeignKey(WebPushDevice, on_delete=models.SET_NULL, null=True, blank=True, related_name="notifications_device_sent") + + title = models.TextField() + body = models.TextField() + + def __str__(self): + return f"Notification sent to {self.target} at {self.date_sent} ({self.title})" diff --git a/intranet/apps/notifications/serializers.py b/intranet/apps/notifications/serializers.py new file mode 100644 index 00000000000..b98eca0dcf1 --- /dev/null +++ b/intranet/apps/notifications/serializers.py @@ -0,0 +1,9 @@ +from push_notifications.models import WebPushDevice +from rest_framework import serializers + + +class WebPushDeviceSerializer(serializers.ModelSerializer): + class Meta: + model = WebPushDevice + fields = ["registration_id", "p256dh", "auth", "user"] + read_only_fields = ["user"] diff --git a/intranet/apps/notifications/tasks.py b/intranet/apps/notifications/tasks.py index 19edf424e33..d0d937b8758 100644 --- a/intranet/apps/notifications/tasks.py +++ b/intranet/apps/notifications/tasks.py @@ -1,9 +1,18 @@ +# pylint: disable=too-many-arguments + 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 +24,114 @@ def email_send_task(*args, **kwargs): kwargs["custom_logger"] = logger return emails.email_send(*args, **kwargs) + + +# Can't wrap the notification functions into a much cleaner class because celery doesn't support it :( + + +@shared_task +def send_notification_to_device( + device: WebPushDevice, + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + try: + device.send_message(dumped_json) + except Exception as e: # pylint: disable=broad-except # Lots of things can go wrong with individual device subscriptions + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.DEVICE, + device_sent=device, + ) + + +@shared_task +def send_notification_to_user( + user, + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + for device in WebPushDevice.objects.filter(user=user): + try: + device.send_message(dumped_json) + except Exception as e: # pylint: disable=broad-except + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.USER, + user_sent=user, + ) + + +@shared_task +def send_bulk_notification( + filtered_objects: QuerySet[WebPushDevice], + title: str, + body: str, + data: Dict[str, str], + icon: str = static("img/logos/touch/touch-icon192.png"), + badge: str = static("img/logos/Icon-76@2x.png"), +) -> None: + dumped_json = json.dumps( + { + "title": title, + "body": body, + "icon": icon, + "badge": badge, + "data": data, + } + ) + + if settings.ENABLE_WEBPUSH: + for device in filtered_objects: + try: + device.send_message(dumped_json) + except Exception as e: # pylint: disable=broad-except + logger.error("An error occurred while trying to send a webpush notification: %s", e) + + obj = WebPushNotification.objects.create( + title=title, + body=body, + target=WebPushNotification.Targets.DEVICE_QUERYSET, + ) + + obj.device_queryset_sent.set(filtered_objects) + + +@shared_task +def remove_inactive_subscriptions(): + inactive_subscriptions = WebPushDevice.objects.filter(active=False) + inactive_subscriptions.delete() diff --git a/intranet/apps/notifications/tests.py b/intranet/apps/notifications/tests.py new file mode 100644 index 00000000000..482a0182226 --- /dev/null +++ b/intranet/apps/notifications/tests.py @@ -0,0 +1,243 @@ +# pylint: disable=no-member,unused-argument + +from unittest import mock +from unittest.mock import ANY + +from django.urls import reverse +from push_notifications.models import WebPushDevice +from rest_framework.response import Response + +from intranet.apps.notifications.models import WebPushNotification +from intranet.apps.notifications.tasks import send_bulk_notification, send_notification_to_device, send_notification_to_user +from intranet.test.ion_test import IonTestCase + + +class NotificationsWebpushTest(IonTestCase): + """Tests for the notifications/webpush module, including api""" + + def setUp(self): + self.endpoint = "push.api.example.com/example/endpoint/id" + self.mock_device = mock.Mock() + self.mock_device.registration_id = self.endpoint + self.mock_device.auth = "authtest" + self.mock_device.p256dh = "p256dhtest" + self.user = self.login() + + def create_webpush_device(self, user, registration_id): + return WebPushDevice.objects.create( + registration_id=registration_id, + p256dh=self.mock_device.p256dh, + auth=self.mock_device.auth, + user=user, + ) + + @mock.patch("intranet.apps.notifications.api.GetApplicationServerKey.get") + def test_get_app_server_key(self, mock_view): + mock_view.return_value = Response({"applicationServerKey": "mock-key"}, status=200) + + response = self.client.get(reverse("api_get_vapid_application_server_key")) + + self.assertEqual(response.status_code, 200) + self.assertIn("applicationServerKey", response.json()) + + def test_webpush_subscription(self): + response = self.client.post( + reverse("api_webpush_subscribe"), + format="json", + data={ + "registration_id": self.mock_device.registration_id, + "p256dh": self.mock_device.p256dh, + "auth": self.mock_device.auth, + }, + ) + + self.assertEqual(response.status_code, 201) + + self.assertEqual(WebPushDevice.objects.count(), 1) + obj = WebPushDevice.objects.get(registration_id=self.mock_device.registration_id) + self.assertEqual(obj.user, self.user) + + def test_webpush_unsubscribe(self): + self.create_webpush_device(self.user, self.mock_device.registration_id) + + self.assertEqual(WebPushDevice.objects.count(), 1) + + response = self.client.post( + reverse("api_webpush_unsubscribe"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + + self.assertEqual(WebPushDevice.objects.count(), 0) + + def test_webpush_update_subscription(self): + self.create_webpush_device(self.user, self.mock_device.registration_id) + + new_registration_id = "push.api.example.com/new/unique/id" + new_p256dh = "p256dhalt" + new_auth = "authalt" + + response = self.client.post( + reverse("api_webpush_update_subscription"), + format="json", + data={ + "old_registration_id": self.mock_device.registration_id, + "registration_id": new_registration_id, + "p256dh": new_p256dh, + "auth": new_auth, + }, + ) + + self.assertEqual(response.status_code, 200) + + device = WebPushDevice.objects.filter(user=self.user).first() + + self.assertEqual(device.registration_id, new_registration_id) + self.assertEqual(device.p256dh, new_p256dh) + self.assertEqual(device.auth, new_auth) + + def test_webpush_subscription_status(self): + response = self.client.post( + reverse("api_webpush_subscription_status"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], False) + + response = self.client.post( + reverse("api_webpush_subscribe"), + format="json", + data={ + "registration_id": self.mock_device.registration_id, + "p256dh": self.mock_device.p256dh, + "auth": self.mock_device.auth, + }, + ) + + self.assertEqual(response.status_code, 201) + + response = self.client.post( + reverse("api_webpush_subscription_status"), + format="json", + data={ + "endpoint": self.mock_device.registration_id, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], True) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_user_message(self, webpush_device_mock): + device = self.create_webpush_device(self.user, self.mock_device.registration_id) + + title = "example" + body = "notification" + url = "example.com" + + send_notification_to_user(user=self.user, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(device, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.USER) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_device_message(self, webpush_device_mock): + device = self.create_webpush_device(self.user, self.mock_device.registration_id) + + title = "example" + body = "notification" + url = "example.com" + + device = WebPushDevice.objects.filter(user=self.user).first() + + send_notification_to_device(device=device, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(device, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.DEVICE) + + @mock.patch("push_notifications.models.WebPushDevice.send_message", autospec=True) + def test_webpush_send_bulk_message(self, webpush_device_mock): + self.create_webpush_device(self.user, self.mock_device.registration_id) + self.create_webpush_device(self.user, "push.api.example.com/unique/id") + + title = "example" + body = "notification" + url = "example.com" + + filtered_objects = WebPushDevice.objects.filter(user=self.user) + + send_bulk_notification(filtered_objects=filtered_objects, title=title, body=body, data={"url": url}) + + WebPushDevice.send_message.assert_called_with(ANY, ANY) + + self.assertEqual(WebPushNotification.objects.count(), 1) + + notification = WebPushNotification.objects.first() + self.assertEqual(notification.target, notification.Targets.DEVICE_QUERYSET) + + def test_webpush_notif_list_view(self): + self.user = self.make_admin() + response = self.client.get(reverse("notif_webpush_list")) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_list.html") + + def test_webpush_notif_device_info_view(self): + self.user = self.make_admin() + device = self.create_webpush_device(user=self.user, registration_id=self.mock_device.registration_id) + + WebPushNotification.objects.create( + title="example", + body="description", + target=WebPushNotification.Targets.DEVICE, + device_sent=device, + ) + + response = self.client.get(reverse("notif_webpush_device_view", kwargs={"model_id": WebPushNotification.objects.all().first().id})) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_device_info.html") + + @mock.patch("intranet.apps.notifications.tasks.send_bulk_notification.delay", autospec=True) + def test_webpush_post_view(self, send_mock): + self.user = self.make_admin() + self.create_webpush_device(self.user, self.mock_device.registration_id) + response = self.client.get(reverse("notif_webpush_post_view")) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "notifications/webpush_post.html") + + title = "example" + body = "notification" + url = "https://www.example.com" + + response = self.client.post( + reverse("notif_webpush_post_view"), + { + "title": title, + "body": body, + "url": url, + }, + ) + + # We can't assert if send_mock was called with specific argument values... + # because mock doesn't support comparing Django objects. + # Instead, it changes the id of the object when mocking, meaning they can't be compared + + send_mock.assert_called_once_with(title=ANY, body=ANY, data=ANY, filtered_objects=ANY) diff --git a/intranet/apps/notifications/urls.py b/intranet/apps/notifications/urls.py index 8fb3cb99808..96c27da0ef5 100644 --- a/intranet/apps/notifications/urls.py +++ b/intranet/apps/notifications/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path +from django.views.generic import TemplateView from . import views @@ -8,4 +9,18 @@ re_path(r"^/chrome/getdata$", views.chrome_getdata_view, name="notif_chrome_getdata"), re_path(r"^/gcm/post$", views.gcm_post_view, name="notif_gcm_post"), re_path(r"^/gcm/list$", views.gcm_list_view, name="notif_gcm_list"), + re_path(r"^/webpush/list$", views.webpush_list_view, name="notif_webpush_list"), + re_path(r"^/webpush/list/(?P\d+)$", views.webpush_device_info_view, name="notif_webpush_device_view"), + re_path( + r"^/webpush/ios/setup$", + TemplateView.as_view(template_name="notifications/ios_notifications_guide.html", content_type="text/html"), + name="ios_notif_setup", + ), + re_path(r"^/webpush/post$", views.webpush_post_view, name="notif_webpush_post_view"), + re_path(r"^/webpush/schedule$", views.webpush_schedule_view, name="notif_webpush_schedule_view"), + re_path( + r"^/webpush/manage$", + TemplateView.as_view(template_name="notifications/manage.html", content_type="text/html"), + name="manage_push_notifs", + ), ] diff --git a/intranet/apps/notifications/utils.py b/intranet/apps/notifications/utils.py new file mode 100644 index 00000000000..d7350a39925 --- /dev/null +++ b/intranet/apps/notifications/utils.py @@ -0,0 +1,32 @@ +import datetime +from typing import Dict + +from intranet import settings +from intranet.apps.bus.tasks import push_bus_notifications +from intranet.apps.eighth.tasks import push_eighth_reminder_notifications, push_glance_notifications + + +def truncate_content(content: str) -> str: + if len(content) > 200: + return content[:200] + "..." + return content + + +def truncate_title(title: str) -> str: + if len(title) > 50: + return title[:50] + "..." + return title + + +def return_all_notification_schedules() -> Dict[str, Dict[str, datetime.datetime]]: + schedules = { + "Bus": {}, + "Eighth": {}, + } + schedules["Bus"]["Bus location notification @ dismissal"] = push_bus_notifications(True, True) + schedules["Eighth"][f"Sign up reminder before blocks lock @ {settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} min before"] = ( + push_eighth_reminder_notifications(True, True) + ) + schedules["Eighth"]["Glance notification @ eighth period start"] = push_glance_notifications(True, True) + + return schedules diff --git a/intranet/apps/notifications/views.py b/intranet/apps/notifications/views.py index 0e8c6da9cae..cfb66151ce4 100644 --- a/intranet/apps/notifications/views.py +++ b/intranet/apps/notifications/views.py @@ -1,16 +1,28 @@ import json import logging +import os import requests from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.http import HttpResponse +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import FileResponse, HttpResponse from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt +from push_notifications.models import WebPushDevice +from ...celery import app +from ..bus.tasks import push_bus_notifications +from ..eighth.tasks import push_eighth_reminder_notifications, push_glance_notifications +from ..schedule.models import Day from ..schedule.notifications import chrome_getdata_check -from .models import GCMNotification, NotificationConfig +from ..users.models import User +from .forms import SendPushNotificationForm +from .models import GCMNotification, NotificationConfig, WebPushNotification +from .tasks import send_bulk_notification +from .utils import return_all_notification_schedules logger = logging.getLogger(__name__) @@ -106,7 +118,7 @@ def chrome_setup_view(request): @login_required def gcm_list_view(request): - if not request.user.has_admin_permission("notifications"): + if not request.user.is_notifications_admin: return redirect("index") gcm_notifs = GCMNotification.objects.all().order_by("-time") posts = [] @@ -163,7 +175,7 @@ def gcm_post(nc_users, data, user=None, request=None): @login_required def gcm_post_view(request): - if not request.user.has_admin_permission("notifications"): + if not request.user.is_notifications_admin: return redirect("index") try: has_tokens = settings.GCM_AUTH_KEY and settings.GCM_PROJECT_ID @@ -200,3 +212,126 @@ def get_gcm_schedule_uids(): nc_all = NotificationConfig.objects.exclude(gcm_token=None).exclude(gcm_optout=True) nc = nc_all.filter(user__receive_schedule_notifications=True) return nc.values_list("id", flat=True) + + +@login_required +def webpush_list_view(request): + if not request.user.is_notifications_admin: + return redirect("index") + notifications = WebPushNotification.objects.all().order_by("-date_sent") + + paginator = Paginator(notifications, 20) + + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render( + request, + "notifications/webpush_list.html", + { + "notifications": notifications, + "page_obj": page_obj, + "targets": WebPushNotification.Targets, + "paginator": paginator, + }, + ) + + +@login_required +def webpush_device_info_view(request, model_id=None): + if not request.user.is_notifications_admin: + return redirect("index") + notifications = WebPushNotification.objects.filter(id=model_id).first() + notification_target = notifications.target + + if notifications is not None: + if notification_target == WebPushNotification.Targets.DEVICE: + notifications = notifications.device_sent + elif notification_target == WebPushNotification.Targets.DEVICE_QUERYSET: + notifications = notifications.device_queryset_sent.all() + else: + messages.error(request, "The notification type cannot be found or is 'Targets.USER'") + return redirect("index") + else: + messages.error(request, f"Can't find notification with id {model_id}") + return redirect("index") + + if notification_target == WebPushNotification.Targets.DEVICE_QUERYSET: + paginator = Paginator(notifications, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + else: + page_obj = None + paginator = None + + return render( + request, + "notifications/webpush_device_info.html", + { + "notifications": notifications, + "page_obj": page_obj, + "paginator": paginator, + }, + ) + + +@login_required() +def webpush_post_view(request): + if not request.user.is_notifications_admin: + return redirect("index") + + if request.method == "POST": + form = SendPushNotificationForm(data=request.POST) + + if form.is_valid(): + if not form.cleaned_data["users"].exists() and not form.cleaned_data["groups"].exists(): + devices = WebPushDevice.objects.all() + else: + group_users = User.objects.filter(groups__in=form.cleaned_data["groups"]) + + devices = WebPushDevice.objects.filter(Q(user__in=form.cleaned_data["users"]) | Q(user__in=group_users)) + + send_bulk_notification.delay( + title=form.cleaned_data["title"], body=form.cleaned_data["body"], data={"url": form.cleaned_data["url"]}, filtered_objects=devices + ) + + messages.success(request, "Sent post notification.") + else: + messages.error(request, "Form invalid.") + + send_push_notification_form = SendPushNotificationForm() + + return render( + request, + "notifications/webpush_post.html", + { + "form": send_push_notification_form, + }, + ) + + +def webpush_schedule_view(request): + if not request.user.is_notifications_admin: + return redirect("index") + + if request.method == "POST": + push_eighth_reminder_notifications.delay(True) + push_glance_notifications.delay(True) + push_bus_notifications.delay(True) + + messages.success(request, "Rescheduled tasks.") + + day = Day.objects.today() + + context = { + "schedules": return_all_notification_schedules(), + "day": day if day else "today: no schedule", + "scheduled_tasks": app.control.inspect().scheduled(), + } + + return render(request, "notifications/webpush_schedule.html", context) + + +def serve_serviceworker(request): + file_path = os.path.join(settings.STATICFILES_DIRS[0], "serviceworker.js") + return FileResponse(open(file_path, "rb"), content_type="application/javascript") diff --git a/intranet/apps/polls/forms.py b/intranet/apps/polls/forms.py index f6a61172865..41469c73f98 100644 --- a/intranet/apps/polls/forms.py +++ b/intranet/apps/polls/forms.py @@ -5,6 +5,13 @@ class PollForm(forms.ModelForm): + send_notification = forms.BooleanField( + initial=True, + required=False, + help_text="This will send a notification to eligible students asking them to vote in this poll", + label="Send notification", + ) + def clean_description(self): desc = self.cleaned_data["description"] # SAFE HTML @@ -20,3 +27,6 @@ class Meta: "is_secret": "This will prevent Ion administrators from viewing individual users' votes.", "is_election": "Enable election formatting and results features.", } + + # We need to make sure the send_notification field doesn't look out of place on the form + field_order = Meta.fields[:4] + ["send_notification"] + Meta.fields[4:] diff --git a/intranet/apps/polls/notifications.py b/intranet/apps/polls/notifications.py new file mode 100644 index 00000000000..7a31df5eb81 --- /dev/null +++ b/intranet/apps/polls/notifications.py @@ -0,0 +1,36 @@ +from celery import shared_task +from django.db.models import Q +from django.urls import reverse +from django.utils.html import strip_tags +from push_notifications.models import WebPushDevice + +from intranet import settings +from intranet.apps.notifications.tasks import send_bulk_notification +from intranet.apps.notifications.utils import truncate_content, truncate_title +from intranet.apps.polls.models import Poll +from intranet.apps.users.models import User + + +@shared_task +def send_poll_notification(obj: Poll) -> None: + """Send a (Web)push notification asking all users who can see the poll to vote + + obj: The poll object + + """ + + if not obj.groups.all(): + users = User.objects.filter(push_notification_preferences__poll_notifications=True) + devices = WebPushDevice.objects.filter(user__in=users) + else: + users = User.objects.filter(Q(groups__in=obj.groups.all()) & Q(push_notification_preferences__poll_notifications=True)) + devices = WebPushDevice.objects.filter(user__in=users) + + send_bulk_notification.delay( + filtered_objects=devices, + title=f"New Poll: {truncate_title(obj.title)}", + body=truncate_content(strip_tags(obj.description)), + data={ + "url": settings.PUSH_NOTIFICATIONS_BASE_URL + reverse("poll_vote", args=[obj.id]), + }, + ) diff --git a/intranet/apps/polls/views.py b/intranet/apps/polls/views.py index f9274628039..d817e0cfc06 100644 --- a/intranet/apps/polls/views.py +++ b/intranet/apps/polls/views.py @@ -21,6 +21,7 @@ from ..auth.decorators import deny_restricted from .forms import PollForm from .models import Answer, Choice, Poll, Question +from .notifications import send_poll_notification logger = logging.getLogger(__name__) @@ -662,6 +663,9 @@ def add_poll_view(request): process_question_data(instance, question_data) + if request.POST.get("send_notification"): + send_poll_notification.apply_async(args=(instance,), eta=instance.start_time) + messages.success(request, "The poll has been created.") return redirect("polls") else: diff --git a/intranet/apps/preferences/forms.py b/intranet/apps/preferences/forms.py index 10e027f7822..583f5b13fbc 100644 --- a/intranet/apps/preferences/forms.py +++ b/intranet/apps/preferences/forms.py @@ -3,7 +3,9 @@ from django import forms from django.contrib.auth import get_user_model +from ... import settings from ..bus.models import Route +from ..notifications.models import UserPushNotificationPreferences from ..users.models import Email, Grade, Phone, Website logger = logging.getLogger(__name__) @@ -131,6 +133,32 @@ class Meta: fields = ["url"] +class PushNotificationOptionsForm(forms.ModelForm): + class Meta: + model = UserPushNotificationPreferences + fields = [ + "eighth_reminder_notifications", + "eighth_waitlist_notifications", + "glance_notifications", + "announcement_notifications", + "poll_notifications", + "bus_notifications", + ] + + help_texts = { + "eighth_reminder_notifications": f"Receive reminder notifications to sign up for eighth period if you " + f"haven't signed up for one " + f"{settings.PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES} " + f"minutes prior to when blocks lock", + "eighth_waitlist_notifications": "Receive notifications when waitlisted for an activity. Must be enabled to use the waitlist feature", + "glance_notifications": "Receive your eighth period glance (a short message telling you which activities " + "you signed up for) as a notification when eighth period starts", + "announcement_notifications": "Receive notifications whenever an announcement is posted on Ion", + "poll_notifications": "Receive notifications whenever a poll you can vote in is available", + "bus_notifications": "Receive a notification at dismissal telling you your bus location and if it's delayed or not", + } + + PhoneFormset = forms.inlineformset_factory(get_user_model(), Phone, form=PhoneForm, extra=1) EmailFormset = forms.inlineformset_factory(get_user_model(), Email, form=EmailForm, extra=1) WebsiteFormset = forms.inlineformset_factory(get_user_model(), Website, form=WebsiteForm, extra=1) diff --git a/intranet/apps/preferences/views.py b/intranet/apps/preferences/views.py index 6bcbf78337c..946047e6aeb 100644 --- a/intranet/apps/preferences/views.py +++ b/intranet/apps/preferences/views.py @@ -5,12 +5,21 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect, render +from django.shortcuts import redirect, render, reverse from ..auth.decorators import eighth_admin_required from ..bus.models import Route +from ..notifications.models import UserPushNotificationPreferences from ..users.models import Email -from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm +from .forms import ( + BusRouteForm, + DarkModeForm, + EmailFormset, + NotificationOptionsForm, + PreferredPictureForm, + PrivacyOptionsForm, + PushNotificationOptionsForm, +) # from .forms import (BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PhoneFormset, PreferredPictureForm, PrivacyOptionsForm, # WebsiteFormset) @@ -18,7 +27,6 @@ logger = logging.getLogger(__name__) - """ NOTE: Phone and website information have been disabled because of privacy reasons. """ @@ -197,6 +205,17 @@ def get_notification_options(user): return notification_options +def get_push_notifications_options(user): + return { + "eighth_reminder_notifications": user.push_notification_preferences.eighth_reminder_notifications, + "eighth_waitlist_notifications": user.push_notification_preferences.eighth_waitlist_notifications, + "glance_notifications": user.push_notification_preferences.glance_notifications, + "announcement_notifications": user.push_notification_preferences.announcement_notifications, + "poll_notifications": user.push_notification_preferences.poll_notifications, + "bus_notifications": user.push_notification_preferences.bus_notifications, + } + + def save_notification_options(request, user): notification_options = get_notification_options(user) notification_options_form = NotificationOptionsForm(user, data=request.POST, initial=notification_options) @@ -219,6 +238,17 @@ def save_notification_options(request, user): return notification_options_form +def save_push_notifications_options(request, user): + push_notifications_options = get_push_notifications_options(user) + obj, _ = UserPushNotificationPreferences.objects.get_or_create(user=user) + + push_notifications_options_form = PushNotificationOptionsForm(data=request.POST, initial=push_notifications_options, instance=obj) + if push_notifications_options_form.is_valid() and push_notifications_options_form.has_changed(): + push_notifications_options_form.save() + + return push_notifications_options_form + + def get_bus_route(user): """Get a user's bus route to pass as an initial value to a BusRouteForm.""" @@ -293,38 +323,46 @@ def save_dark_mode_settings(request, user): @login_required def preferences_view(request): """View and process updates to the preferences page.""" + # pylint: disable=E0606 + user = request.user if request.method == "POST": - logger.debug("Preparing to update user preferences for user %s", request.user.id) - # phone_formset, email_formset, website_formset, errors = save_personal_info(request, user) - _, email_formset, _, errors = save_personal_info(request, user) - if user.is_student: - preferred_pic_form = save_preferred_pic(request, user) - bus_route_form = save_bus_route(request, user) - """ - The privacy options form is disabled due to the - permissions feature being unused and changes to school policy. - """ - # privacy_options_form = save_privacy_options(request, user) - privacy_options_form = None - else: - preferred_pic_form = None - bus_route_form = None - privacy_options_form = None - notification_options_form = save_notification_options(request, user) + if request.POST.get("updatepushprefs", "").lower() == "": + logger.debug("Preparing to update user preferences for user %s", request.user.id) + # phone_formset, email_formset, website_formset, errors = save_personal_info(request, user) + _, email_formset, _, errors = save_personal_info(request, user) + if user.is_student: + preferred_pic_form = save_preferred_pic(request, user) + bus_route_form = save_bus_route(request, user) + """ + The privacy options form is disabled due to the + permissions feature being unused and changes to school policy. + """ + # privacy_options_form = save_privacy_options(request, user) + privacy_options_form = None + else: + preferred_pic_form = None + bus_route_form = None + privacy_options_form = None + notification_options_form = save_notification_options(request, user) + + dark_mode_form = save_dark_mode_settings(request, user) - dark_mode_form = save_dark_mode_settings(request, user) + for error in errors: + messages.error(request, error) - for error in errors: - messages.error(request, error) + try: + save_gcm_options(request, user) + except AttributeError: + pass - try: - save_gcm_options(request, user) - except AttributeError: - pass + return redirect("preferences") - return redirect("preferences") + elif request.POST.get("updatepushprefs").lower() == "true": + push_notifications_options_form = save_push_notifications_options(request, user) + messages.success(request, "Push notification settings updated.") + return redirect(f"{reverse('preferences')}?pushprefs=true") else: # phone_formset = PhoneFormset(instance=user, prefix="pf") @@ -354,9 +392,24 @@ def preferences_view(request): notification_options = get_notification_options(user) notification_options_form = NotificationOptionsForm(user, initial=notification_options) + push_notifications_options = get_push_notifications_options(user) + push_notifications_options_form = PushNotificationOptionsForm(initial=push_notifications_options) dark_mode_form = DarkModeForm(user, initial={"dark_mode_enabled": user.dark_mode_properties.dark_mode_enabled}) + enable_get_params = request.COOKIES.get("enableGetParams", "false") + + if request.method == "GET" and enable_get_params == "true": + if request.GET.get("success", "") != "": + messages.success(request, f"Success: {request.GET.get('success')}") + elif request.GET.get("error", "") != "": # Success messages take precedence + messages.error(request, f"An error occurred: {request.GET.get('error')}") + + user_agent = request.user_agent + is_ios = user_agent.is_mobile and user_agent.os.family == "iOS" + browser_supported = supports_webpush_notifications(user_agent) + open_push_notifs_prefs = request.GET.get("pushprefs") if enable_get_params == "true" else "false" + context = { # "phone_formset": phone_formset, "email_formset": email_formset, @@ -366,6 +419,11 @@ def preferences_view(request): "notification_options_form": notification_options_form, "bus_route_form": bus_route_form if settings.ENABLE_BUS_APP else None, "dark_mode_form": dark_mode_form, + "open_push_notif_prefs": open_push_notifs_prefs, + "push_notifications_options_form": push_notifications_options_form, + "is_ios": is_ios, + "browser_supported": browser_supported, + "ENABLE_WAITLIST": settings.ENABLE_WAITLIST, } return render(request, "preferences/preferences.html", context) @@ -403,3 +461,17 @@ def privacy_options_view(request): else: context = {"profile_user": user} return render(request, "preferences/privacy_options.html", context) + + +def supports_webpush_notifications(user_agent): + """Detect browsers that support webpush notifications.""" + if (user_agent.browser.family in ("Chrome", "Opera")) and user_agent.browser.version[0] >= 42: + return True + elif user_agent.browser.family == "Firefox" and user_agent.browser.version[0] >= 44: + return True + elif "Safari" in user_agent.browser.family and user_agent.os.family == "iOS" and user_agent.os.version[0] >= 16 and user_agent.os.version[1] >= 4: + return True + elif "Safari" in user_agent.browser.family and user_agent.os.family == "macOS" and user_agent.browser.version[0] >= 16: + return True + else: + return False diff --git a/intranet/apps/schedule/models.py b/intranet/apps/schedule/models.py index 95cfd3a2794..7f60b67de2d 100644 --- a/intranet/apps/schedule/models.py +++ b/intranet/apps/schedule/models.py @@ -144,6 +144,11 @@ def end_time(self): """Return time the school day ends""" return self.day_type.end_time + @property + def end_datetime(self): + """Return a timezone aware datetime of when the school day ends""" + return timezone.make_aware(self.day_type.end_time.date_obj(timezone.now()), timezone.get_current_timezone()) + def __str__(self): return f"{self.date}: {self.day_type}" diff --git a/intranet/apps/templatetags/dates.py b/intranet/apps/templatetags/dates.py index 076213f5103..096e2ff1e67 100644 --- a/intranet/apps/templatetags/dates.py +++ b/intranet/apps/templatetags/dates.py @@ -1,6 +1,7 @@ import logging from datetime import datetime +from dateutil import parser from django import template from django.utils import timezone @@ -73,3 +74,13 @@ def fuzzy_date(date): return date.strftime("next %A") else: return date.strftime("%A, %B %d, %Y") + + +@register.filter() +def strftime(date, date_format: str = "%B %d, %Y at %I:%M %p"): + try: + date = parser.parse(date) + + return date.strftime(date_format) + except (ValueError, TypeError): + return date diff --git a/intranet/apps/templatetags/forms.py b/intranet/apps/templatetags/forms.py index 56efe5f9f22..224b6e79929 100644 --- a/intranet/apps/templatetags/forms.py +++ b/intranet/apps/templatetags/forms.py @@ -32,3 +32,8 @@ def field_array_size(field): if re.match(rf"^{prefix}_(\d+)$", field_name): count += 1 return count + + +@register.filter +def field_type(field): + return field.field.widget.__class__.__name__ diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py index 4ff84fe0bde..23a06720493 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -21,6 +21,7 @@ from ..bus.models import Route from ..eighth.models import EighthBlock, EighthSignup, EighthSponsor from ..groups.models import Group +from ..notifications.models import UserPushNotificationPreferences from ..polls.models import Poll from ..preferences.fields import PhoneField @@ -741,6 +742,17 @@ def is_board_admin(self) -> bool: return self.has_admin_permission("board") + @property + def is_notifications_admin(self) -> bool: + """Checks if user is a notifications admin. + + Returns: + Whether this user is a notifications admin. + + """ + + return self.has_admin_permission("notifications") + @property def is_global_admin(self) -> bool: """Checks if user is a global admin. @@ -1094,6 +1106,8 @@ def __getattr__(self, name): return UserProperties.objects.get_or_create(user=self)[0] elif name == "dark_mode_properties": return UserDarkModeProperties.objects.get_or_create(user=self)[0] + elif name == "push_notification_preferences": + return UserPushNotificationPreferences.objects.get_or_create(user=self)[0] raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") def __str__(self): diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py index 492fc397c56..e3e76d3380a 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -1,3 +1,5 @@ +# pylint: disable=C0302 + import datetime import logging import os @@ -103,6 +105,8 @@ # App and functionality availability toggles ENABLE_WAITLIST = False # Eighth waitlist. WARNING: Enabling the waitlist causes severe performance issues +ENABLE_WEBPUSH = True # Enable webpush notifications + ENABLE_BUS_APP = True ENABLE_BUS_DRIVER = True @@ -303,6 +307,34 @@ os.path.join(PROJECT_ROOT, "static") ] +# Settings for Webpush (used in the django-push-notifications library) +PUSH_NOTIFICATIONS_SETTINGS = { + "WP_PRIVATE_KEY": os.path.join(os.path.dirname(PROJECT_ROOT), "keys", "webpush", "private_key.pem"), + "WP_CLAIMS": {"sub": "mailto:intranet@tjhsst.edu"}, +} + +# Used in instances where the request object is not available, so we can't build an absolute URI +PUSH_NOTIFICATIONS_BASE_URL = "https://ion.tjhsst.edu" if PRODUCTION else "http://localhost:8080" + +# How many minutes before an eighth period locks should notifications be sent out +PUSH_NOTIFICATIONS_EIGHTH_REMINDER_MINUTES = 40 + +# Determines the name/location of each bus spot when sending Webpush notifications +# Keys are lowercase so they don't look out of place when formatted into the notification body +# The values are bus spot ID's. I.e. "_9" is written as 9 + +PUSH_ROUTE_TRANSLATIONS = { + "curb, left section": {7, 8, 9}, + "curb, middle section": {3, 4, 5, 6}, + "curb, right section": {1, 2, 41}, + "front row, left section": {19, 20, 21, 22}, + "back row, left section": {42, 43, 44, 45}, + "front row, middle section": {14, 15, 16, 17, 18}, + "back row, middle section": {27, 28, 29, 30}, + "front row, right section": {10, 11, 12, 13}, + "back row, right section": {23, 24, 25, 26}, +} + # List of finder classes that know how to find static files in # various locations. STATICFILES_FINDERS = [ @@ -688,6 +720,7 @@ def get_month_seconds(): "simple_history", # django-simple-history "django_referrer_policy", "django_user_agents", + "push_notifications", # django-push-notifications ] # Django Channels Configuration (we use this for websockets) @@ -935,6 +968,26 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th "schedule": celery.schedules.crontab(day_of_month=3, hour=1), "args": (), }, + "push-eighth-reminder-notifications": { + "task": "intranet.apps.eighth.tasks.push_eighth_reminder_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=1), + "args": [True], + }, + "push-glance-notifications": { + "task": "intranet.apps.eighth.tasks.push_glance_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=1), + "args": [True], + }, + "push-bus-notifications": { + "task": "intranet.apps.bus.tasks.push_bus_notifications", + "schedule": celery.schedules.crontab(hour=0, minute=1), + "args": [True], + }, + "remove-inactive-subscriptions": { + "task": "intranet.apps.notifications.tasks.remove_inactive_subscriptions", + "schedule": celery.schedules.crontab(day_of_week=0, hour=0, minute=0), + "args": (), + }, } MAINTENANCE_MODE = False diff --git a/intranet/static/css/dark/preferences.scss b/intranet/static/css/dark/preferences.scss index f9f72d02142..398b91399a6 100644 --- a/intranet/static/css/dark/preferences.scss +++ b/intranet/static/css/dark/preferences.scss @@ -3,3 +3,54 @@ select { color: white; } +.modal { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: rgb(30, 30, 30); + margin: 15% auto; + padding: 20px; + border: 1px solid #232323; + width: 80%; + max-width: 500px; + border-radius: 5px; +} + +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.popup-content { + display: none; + position: absolute; + width: 200px; + padding: 10px; + background-color: #3e3e3e; + border: 1px solid #2c2c2c; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.popup-content p { + color: white; +} diff --git a/intranet/static/css/preferences.scss b/intranet/static/css/preferences.scss index 6150b0afebe..f7ed4a4d5cd 100644 --- a/intranet/static/css/preferences.scss +++ b/intranet/static/css/preferences.scss @@ -70,3 +70,61 @@ tr:nth-last-child(2) a.delete-row { z-index: 1; margin-bottom: 15px; } + +.modal { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: rgba(254, 254, 254, 0.9); + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: 500px; + border-radius: 5px; +} + +.close-button { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.close-button:hover, +.close-button:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.popup-link { + font-size: 12px; + margin-top: 3px; +} + +.popup-content { + display: none; + position: absolute; + width: 200px; + padding: 10px; + background-color: #f9f9f9; + border: 1px solid #ccc; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.popup-content p { + color: black; + pointer-events: none; +} diff --git a/intranet/static/img/guides/add_to_home_screen_ios.png b/intranet/static/img/guides/add_to_home_screen_ios.png new file mode 100644 index 0000000000000000000000000000000000000000..23bed14f1c6f0ef67dda1a30c2422050f32798f9 GIT binary patch literal 83149 zcmb4K19N3fw2f`sp4h&zZ95Y?6Wf^Bn%K4`H}=Gs*miC*Fb-Wfijp)k0s#UT7#OmwjD#8(7`QPQ7{n7C%-5F)P7r2|6v=~_3G|}l- z1IkKNQ4|cUF#++-6#AFQ>QrF?2KHGhD?;lX{q z)lGIB>}GW|`Scw?*|-{vA=zNePoEN2X-bVCW+8^ATCx`)-j0x=kF`bV?4C5`NUe< z$30!D84}JBOU@I{-8NA@?I~Z~u*(#FhP~J%o0|Pww{XneJm70w;eJ|!er~gK zy?K4VUolUZOJ~Z-Qr4Oy4g6DJvry);h;vL2lVyQ7w#)6{_bgm(S&g$?p<2usC>f4@ z^U74`9E&)yWb=oUfZtI zzTt{#?9_$=cvDG_`?w-@qM72Ze>UShj44*71e6S-2>smhe>8k~+IMl^35K1|vic?K zcJ!y{YSR-p&ZEA^XD$f*{WRgiPwbFLL9dfNsXR=WzTPpN&&mC&w7^jdYZ&jc3y1-|b$HODx!p+M{r;@ih7 z5<@-R@oAx;R6$8eSY7uAf7j8O-2sU|d{e<~(e?le%$_c1?RLYjtKa)dKJl-O0G0mt z%-3Jl=ReLCqd(s6lw}by8=?FzZbxlSW(z3@rAkvy-XFKiY4iPJfG=lu@Htj9KInCy z;IKEEQ@?`e=hb}nqG@f;m({%M=XHja@)^?#sgm5f;jb!;CN?JbCv)x#aXh#F z=uCPoQI5Uu9%pN<-?*H%a`oCBndH%geC`V(NqjPwYjnk1?Us&SVNX}B@=f7qfkpeG zz0#y3WMn(O_omBr!|tc^l4JgBEu_&_4NV%Ilgx%g?pGVp=}d-ly>D-^V~LiMgp!hy z8Y(Jy{ivGZmmz*UY&H+43mM}#V!Foj#RO87Ado9bY1_n|U40J?)cY+hsau-q{XV~` zn(*cQg0=FpZGGnvf!=s_J;MrX)?TOSV8mWT(r@3#tCmp`-<9IVs<*QWB*;()1dkNy zby)Hv+yiwNSvE|KMpqdKGGR347p5`nlGj;r)aYeCuNxE9qIkX|ywkxi+l);-61e_7 zNbY7$9@+d`!v<#AZ-V->Px>~wgXap5w)tA&3rq06dpI+ zP-_jlc{aX51J?qvmT~8-D$0OB$qYC@%$5Jd#yBn@1<$^`BsNV zhZFqh%T}FRLx4AK$N7~n^Nr?m+~9r4a3c2F32qV|qO_A?#sv_PU=R4kN$XKxG zHVyHg>_FBE>dvs+0Y@(IMlz0@vpMa|xE$A37#JB7+j`Z{wU$YIAJ7&WOd(J8yD!$b zEVTPRbkbsz!-2zVB}?s2q^IrguaE6z3Rx8XNOpB=sdsm{#(t)adzA0)*FBHv)iv*H z@2fWPf*_gY_TRxD?>nKW6J0ldjRToL{|Gp`^|THD1!_%YbJb*Vd^TMHUzK#PdoJ7b z#mbC&e3p@UjGVaaH)c7m6tXVG?w0!0AfzgR{;%_dPQS_3Q)9g(249egIxQ@PWK9zE z9yk1P?HagFV!qWSZYr`YR;o)&iH!N*P4U=l`#+``F2ejfUm$b>l3!iT&GqeU$8`NT zZ)jUd)oZawDRXn-}nMDv0eJee@@b2|J#Pf-{N;Xhtxc&7RVpAA!V|g(7rs;&VS2dDt;dnII^U zAoM`T<+u`tBJ`x9d(XrA)DHe`I$I#xE41Q{w)gCdvF zd^yAd7(JmNQ)NUAc-?Hj|2I^B@=Bj#)BFBd+IG3FR&OL^x%pU89ABX8_ED%>EIn@= z_&MBYni6NZ-#Z$B*Z~%k{?r&;ip=O1WYBov{DnDkA0|5y?k0Q?qhocwA24k*=6pIp z6)3w~=n?ImV(3_$iLQo|3eB*uZL9i(7wq$}mLff!(1`}=K7=kkKLhx*e^nWNyb(ub z$N!Xlf4OAx5RX72H?C7&PAyaZObhsY=keJM#~r%7Xr4KeVvV+4tm$v^zh8Iio%`*5 zcSu4VkN$Yw`%CgDJ5AFzN=LquO`%c0?s=IF$2E}o2oe&Hqgj5**X00=cEsyhK{Ckuy{BA_YVYwgrP>tX;o+?9L1z&ppqPHtRP-}(n zafA}I%2|*6#9#bvgQ(Z+)A}2^7?Zf+t>#u(!h9q^f+?G)+;OvO?sy+?9CIU2Ij5dd zpn)})Buyeq8o(Pu5^#Xtby`MPior_#Y<;1_@4O?*X?dpca+Kp_@s4{giAkfdLqDhL z+C)(#N`Uu<7({6V_;@OAxujPwm&tLOZ4ka}QJ24B#;MioOvho?k*U>dH5o1|Tw%)W z&D5&bQ#ABERS1_n%f0@&{i)@YKr<)Vx4@*c)uBA1daRVEbDl@Vv8t#5BpbCzf3p2s z$CPcq+4W;-r@t?G&??`5q$FoD`&xwDSh3HU@Nt}0aG_TA3%z^qeTRO0L0pqQ-?1^h ztJmb!^RcnYf$^}&grj!*It!ccl4ue%PBcO?%OXa&@M~AU&=vV?+c z({$o5GJ(_-#6V_Dw}5tzfozBDR&R2J{ii3SQ+sULpW&vULnF9?qx}Lc4G$-Os5<07 z*0IhlSkaVxk);b7{u_UQDE-h}BemX}P;!ZcONfVOFAg_{J@^cS-trjEFdk5GTChAT z{rM-czJw*x^>z?Xp--!>6xdFm{LhIAfBB1^tUREJ0=+bKgZ2?ssMzw_GuJ~dB2Xt` zR{+Ab^uHLwk&;+q8$sjRIyoRa&XSPi2tQ*n;paoRf9Aet?{;Zy7L#9;$3#DIgsU`rnezFaXLvJ~n+ZASeb71>v4__*{kurV zq&eqI{N@VA?bCRk<-vIS%yqEwK4x=a{V5$thW&0M;&Tq z+|`cywi~-LT0iCOGw#1SJ%Vzp{C@W$ppD`Fs z&B83%$gZu>YTojj>1uel!>^?9pW>+xv*p?dpWqD^tqx*J1iL^Pz?+}qR4H07Vq;x# zF=2Z?QdKGlo49kUEIF(b1D?(sX7}!<%S*QEsn;I)CIZSc^PQ0fSLt2wD3y*CI2!RK zC8CGBVAY70&;+&6m`jN8uUlYWt4J>^mR7|g0et#VzU9H)lVr+Q=|PrcF0jt6mQtJD zaUKh|-uMO^htMmIF8!TI(3(8E=E5jm(T@s|UA%>BrYm05OFd#V396RF4rDA7B)GV) zf-_W|f*|wMda4lcr%g^TWMgpDgz>&_+Z`R~$GO-NC!7*olIv48g69$j801oG^O6`p ze%T$OV;O&wCZ-s>JyaiFdr%IQV)*&6~zE*uzd6gzqhp-0Sr&P6o`iVX8Gc5Tk zY@bi91JOAw8W!q1Ci&<$q&HqlG1%C9+T`Cp8VIu(o zAKL(LOQv}?&TDo_a4c5u;wWEC`+?HL^Vl433q*N6$c3?<030^DB_|ZNfJmpP?>L|o@B1S-oQXB0B{vDo*{!P? zHF^QuhO%*?o?0rsHyQt+X~d9DE1<7N4$>0Fv)3P$?z3TuHq^F ze!mm2NzdrXB+kBMtEQW8uDO@$gXA)jBwfhS2lrAEqfyLxte9ba5MrV2`XW$bY>j|C zut-BV{^bV!_%UsRaxg%fFOe?4G+dpYK_d4|z6>Fb(t)#q2Tr@f>)#AYub^ueD#fRz z+fL}G#Bw4OD!}@mBwXCT1%@80k^u|7-gieD%!5rcPmm*n;dz2u|_dT=$HkL2}Uv+)Sywm9g>8No1Tv3EkKhIJIg?I`ur zDqX*S+$EVj_|FRQHs);I!6C@@SHI9cQXoF;wiM;=F5y(ZjX|oF*<0=)q%|j(!E&HU z=uFOO3m6hfne762DXTWHOzIb^n#OMXdx^1}RN#r;r9R*I=B;t%;e-WxLE4k8gI(*4 zxvl-A-3$Zc`-D*7eBld&JZ4tDINJpZscLjofPUhc3DS=<#+6d^T>y?m7x669f;Y?M zXuu+_N?wa^UyP*xtgg%W{G%~46BTgvc2<#hSq!8b$Y(x=ip5IX}4>U?@_ zj?I^A(&BY%_HvkEY(0|CVST>L1cJ<0J&5J(tMa(ibN_OR7pOA~Xi5+dVqkv445(x9 zsr;0HURskzdkN2ne#-Rggrmw&g7-jfXXf2B*kwSBVUAweQOV&A=X=8$C!vF>S6Id|v|hAmAd(G-{Q0o(H4D@9M}-Y77WbS#O|*g&wu zW`mknm?u&%^*2}kqp5spWx;2F&*^Z6_2f}42JD3!2m!>%$iiv2mb&G0Zg{rdPI|nl z)PRW|9XU()&53uxIIeGunBfyE7=+3t(cOnB+JXz@m)1ironkHJpAh50iW?ir(Hj_y zQ;KxsejD?`phR);q0kFRwCvN$kr4sf`jHRme2?bsB~hi?{k=Qg{Hy085wGo$AD6Vs zNh(#KTT-`?$ZBi$FGefRX@UnqZjfeJ&1@(LZ=Roc*91Dy^xH-yZ4a7gXHsQ)*`H{Q z(vcZPFxTH}_7$trjIqNI1KoQ~_0a{z(GiZATMmPWwyP53KbigpTckg7dXI)>AZg-c zTlCkjVCD$gVC1<@(6^Wk%OSkH8wg#f^cwlUI3LKYbvtc4OA?lVUo$9ov6aBPlhmW6 zG8I4%J4&9WOK7H($y?o$8M2twtz}lj3AplXjetm?z+poc>x}^f-0pZwb(-oI{=&vr zm#6>`9kI{YuEKBO;bk_pghK?*p%Mb~HxfKT{@ev?-e0XpVRHfl2{5@m7b#FfgD0)^ z60_M(cr}r71kV#F=p1-bEp6G%x%P#7>NF&K*b0I?EFEudi#}kF>0uB*y1iaos#k)C zc7Ic^HV_vdGLWq%ySLv4EsRe)iiKHp@3f^KG=|$!$_e}nX;DVynVXRGkN;Egz@Yts zsn#O+-E*xzL!WbJRG{v@kh+9yeAyyJwo#0|X&(~|#%?i`TckoqJS~)8V-jylpR+id z%Wl3yc~n)Yd^)d&v`ndZ597Nz{2#{4VcrXz#;sg9A{LFy4tb!B7~Et!;dBA)9xrvQ zfw3#T%0X-@{io7ixDkO|L)_EBxg{<(1IxQJIOA|ms=lNh=Lu1u6$d$A=fpG%PZd=f3Cz((LDzgct*6$7fO#>F`8#zyfAF_1`8R(s)|rj&+z zhD-PDr?tRnuY%ZKlcE__{%w_v-S;3)O&Y`K0k^u8Z+~QX$0d+kdG`RkPS zSF<*+NFL3S!D{OU$ajpQthnBWYpI2DIKn)Z~psZy1nb z~A}O9=P#r>0(_$V>iY3F77>m(?%>ZEkvS@JMWNf^A*l zJB3r{Vz(#h#N|pQrLV=joQ)!R&TQHqXOMq>!>~4WTL!xXe_k1XV^8WFH_GNkJ3Q}8 zVdYlIY@s;pM6GczIIcc(!p&R>bWz4t)-FUGk8RlQQkPjPuS2~QzOszeq61bUPWvs` zJW*7m30(VxpAMgHMXTA*;@KMx?0oHjv^=Anc<7EvAE#Vb^?&BCvKU1cyc*MqUdmjH zkPLE`ZJK0&HG6dxc)7L;O>%RG#iK+lQ0O~28_0>Up1J$IjQ{nYSSSoKUAvU*R6_$N zVTh5Z;xyDMltIL0VUaW*YG6&C^lND2^E?9ar;f~~)8{q0RaN{Z)%4*KW9+oga|87Tu!qC>`0 z_}6e!0i{SkUD%duN^ro0@yWZpRAxc4C`I5d??4nvvHz(%O&97htsm|yNkjgRLy6!? z>`^;0m5%{qD>t$cHFOE-sK+P-Ew+?`!c9H`KIYT=>uDU5Zh`mCeP;&1Jmc*J2$Hv* z%q-6Xtz#a%zDq+)X_|Lc#E93>v@r9tUwlR2SF1qX##Jl!+iDJnk}ki!4$R=bMi|5k zTHRBzB7`-|3qcGVd`Khdst^j7^r;*=tId&G9N>CEs5iJH#l}NWRHy+us1Z)7SZpr90k$c<-i zJi0N%gE|$GhtuNm*;Ur@B{knh@q`GaTGcOb`s=!_*1VMZh`;*J_~gd=SyfHVv|Y_8*}P}!N|d%6TW|dwHU+~2`3GUl?#U!M`_HS`%VZM@VBDKRxr)||WXHHbm63*_7ISWU^)m$5?q#sQAdKWw!#9D9lYtxiip2%Sme zQTe0lsEh&)22snR9Lm58oat0K+|l|0@Ww%b%O82>;F6o6e#A`VFe*x{rV_%EVo5A& z72I-WD*iJmQO(8G1u4WM!VCi510P8D*5P}0o zW{RQUSLp|oNQC}>Sl)=WPzKY2q(a#vEl9g%Z`TrcKs*FW`e>z8QueK2Kk5xqQ)P5_ zquftqoOK@JSw;O&p}3RD!wU?BeI^JP2*EE5Fo@%p;#i;8v{C!U(R;wyqS6M9ghOKp zGqy_#1|@K%46H$EG*~P(lgQLX^3+ew`V`o}n_uA*l(O=2;}NFK>jbYiy*_z=x{UOa;vphqrx?N@ z#+1oWH}S;=RG<{}wX5fo`yr;~}kEoSI!5R%bGXE88Kt)``TuhJC@YO`OZ$Y#y%R)ZLHp@yKMkp&{f zi%u_quq~SHCF$*chwnOmD;5VOImO<%1*9`?iv*AiTVzLLfV7w>z~XS!Q@-M(DIQm| z{oKT>?nTpnz50I*ZMFm6D+Wkh4*%HzN!%{jTDF0&u6_o=9&@tPuK4->4`1WG_79r{ zx3@Oq6J0Lnb%?py#SV0}-r9KPG<%ZK>+y*b93-YlBi(R*C&TDi)xPlooAKz0_UPM9 z^#JRZ zl{J*cUsueK9CJcSfiGaH1V_F6WdT62phsmcn4t&VN36@z%gC*7{`N)$G>gT2Btej| zdxKil(NYaXwYDdD(c$70GJ@s~8_*lNzKGuG}}GX=m9+ zNtL;5mrIuHwC32PNfo6V`TSCEP^vP8nTCwkcEAtlZC7@EF#q52`X68LNcsWkUIwM?59DPX7Um&=@?dehrvZ53G zjOf>XbPMrzmC&zzVI_*d+~1!FCqjfozj%rvHjS3`h?td^&H_YQNuju!m@RE5?;cPNZ|Hqu6t+x z48a5g4iMDmn9OaNL;|bxxk{#_@!S(xTNryu2|X=UoAQEEIs^_NfjLf{LTBasls$AZ z7vuNKOG=HdOE$oMY6u<4Z&fpA)2{+4`6^9GwWbQ@N%W12lS&h^mzW-(buOmz=hS z+;ykUG+|P}uWT-xssJs7P}uhVYu>BZtImuq-*X40eb;GETn0wE64c1J0(9}nxh86P zo6QmAmU!4i$4!sJe(3WfkC6kKdlPY?Vt-kD-dxJ{Y5@a#UQ274)j=<=8-@^}jS-mg z(QmcQtrR@mLQ5>15tRCx=LFjwGuSZhjT;`L zq&-HK?|tBJGDIoYhAaxjY{tx`K3tg%4^`6Pe3FcDniSyb)x_|eoYL<0Gixf$%lJun zTu=i-K10oz76N1S2d=de+~ff6hk}OVUpP!wt+3yx9l%&5BU~{=Cvy@zQle9@b{BW3pOJ)fGm}EmGR)Gmr0-Hj$hI^rBVVl?cN z`TjJd9E2S5&T8eQv#P{l5DLq$j{az42Wp;&(1_!aoIG%kn3!)@QiiTK45aoyr66wm z3JOyDzqh0!>Q4gU4F3H|zI694k~PJF?=`|~Xfy{DO2*_#FNX$iEN1ZJMPbh+M@ zY&uy`oBhdb6x%A#^XPhuYQT+&MX2cLkQp%Jp~Wvz1U~*|4UbUHmFD6bycXn-@@c6a z&ejYzlpvGU&vvIPx4+ytT?F(Tg^!RVK^RDIwJ>gMywi~)>j*Y1X4rl03`5R(aL7N@vq2?QLG{#|`tj5k)y{PMRxEt2FsN=c|(C+Qx)4_yAE zd;dzxAt7F`UX7xddxK5v`RDe2ubSmQ^q1cB+)~^EW(ofDty(A`>G_zicK?_5cQ&@Z zS0*%GFxTU{i?G#UGhWJ{GX^260ARun{L*FFJXIvM7=J>Z6#8Bj!+zUSzE{&6u%Tr3uDlBtkx%-)M6zjy%6~ z8gjnz5t82Cof_5O6|p^s?EW0oamwZe7=S^ zJdF;L`UVh)`xbH|#Gh)^B|{~>XBFuCa3MR~YU{fHr;xeZ13OaFPAe7(2`aG&nSg>D zf{@!zZ1eGgSr3q|(#1;NX3~B9DP*(E(GkUE-~APEl*MVk!b<*^zJ{;5-9J%pnO&q~gA9@p zau-vyz1m{Z-B)Cu&&B(o>gVdwd@#lJ&?TeyN@XaoyNp8>62GuCk%xYgVBsZ)X~?K4 z72gJysRf5VBRU<1b-hrh@+I)~swVTvW~0M}Gw=fNL)2*kN9bMy8aemJuB!B)pwIt-8rb$sf$nf7a;# zi}0{xMX^_KfeW}a7-C1POtl2u5Ad{HzXo-RSvT_xcm$$5ocy!nxA zPz75T;0V1RG`nA*kIw(x4lFNI%x1;+#IQoE2c?TYKP)25c+VP0LTh$+leu$_{Oa*h zt>5yU3wXU2f86lpxokED#ABv~uRITYBqGS>lyHFaqJavKs4l=7CWrIZEn*O5bQEq1 z!gsS9&B9rw_l9rWG!-eyNSlNf5*~(WQs-nuJQ4FHf=F4TA=bDy8UXXf(x~)x$^c^J zFQF(;91&j(x8nvK!L_P1F||Jn$-T%<)({?CIhEv72P;HP1W;HnXT0y9s=ri_^$MlZ zMp+{^Pr)CLASk__#@zRifh;h-9nS^a1!>F-xx&MkkV{7X#PTW?oE(BgQdnnuyQK-u z#bRI2uHCM4swsUQhce0743$lve_=9|#Ukb*NScMFGBy?H99Uw8mSsaL4W{B=d|~gh z%ufsxw13CDuYvN4+1%wkTRu5;oge(m1)i!DI~g2RGba?#5Z+pkQ#tJg1g*B}Mgb&- zec9K6%-uhg1YKA!7j0>6mus?~B#jbNrv#TEeb?2JL#NfSMUSaciy2~2&00@VLHVW< zLvgU1%x2`3L$>%zy78}a-+7mS-wRFMavKLu|CvC0A<(K!Ei%qozuupU`(89gS~p+< zbIIP}F5kbTn#c}i)QrMI*mK7Fl-{7>lm3vwTS^Q;^XR^!Lqglxca{Lr(!+pdrPVm< zcE_b;;g>ySpMVu2ACHsk`v+Kn`ug%zrDAoNnhtjE9715?1jB^&+?wM3K;**7N$hq3 z2Okw1=r(RU45Yq5@3z??iQ- zw7KacJb_M2Q3HysBBl$hw(~^;r}f2K1H0x!J$6`vw{y&lAQ&Y)YO zz(hY3UDuLWE@>i+S?sg@K)wA@XqypqHWUWwX@2*!AV^I2!KJ@*LhakbP!4uYnQk}8 z!XaTJ%bwT5Ef(Wu{MR*ZCo%HO<)fs5UpTQE`R%9W1aStf4CxTE15UlVxQ41J+osR! z<^3S4`su;Ga>=?QFb;+9hOBG%g|NbLN{^k+WE`U7X(0Bb@>7W3&WFz$mApc*Bbc*D zu#x-cs|9#Id`~PbGtV@|GNyRMrG<(dmauFA^NnvcqaFykct2QFkX^=SC|bHiaz1o< zTKivFfldkZ7#bvgE?Q|3T^|afgRR?Gytrgl9l4C-(9U_Fm`4#*pLIdNCBFxZBE$*l zytD}~L9`xjCO7)2YNFri#f^~b9#o7H(sngVic3JJVsW7C`giG-%C=DMU-I2oUDfs# zg;Rt6$J2%fkINdo*vo`fQv<^8z~;Ij+-JC_rs zuUM&B4`s&pDSdfg4<|VXf<-cbwwjA5Gnb7~r0~ZJOI4DwXu_sD8u5HP*J0?1-=7dI zzS>POg+G8fuE#(rOyIW2w`J^x2gFCBv*ki&;+1aXrjc-mIYRRQ^W?`lEt)^!laTk% zCC>_192HViezx0nY}hg(S!2BQJ>Bg`6Ey|-0mc(0gazf-yy<7yhpLIbumoGN*Y9HH zy}Arkae6HsnHjc1DI>KZ>{fedROfYfHbl zPxSlCwX#5~10}M1`&IW`e&DAhA`XjLp#Nzj?FX$M#DT}zV%8atJsokC?%nsr>SH6K zdaR$2=%^?XYom@Ar~SpKR#>@~l>jSlwDJHf+Q=NtIma__!!vF!@Wyw+^k**SZI+Tr9e8|sg1Xx+r6Qg zD0IpLL1!12M!!q@{*ds+235)`^>Xgl$9QRQ4j>VO#oqKk1KbBW+HzBQ-HYTQvhsBK z#ddgzjb_N2(sZ`ht=9omPEa-VMe-uomNuW&+gi8u zTvDWadFX4KI_K1GsDf9@cg8Y(5<+FS3|EOx9jl>=;xTkY0&x^O(MMw-sW$h5K>NiP zASpIy95(v~f!J(!<1g=Om=>G$9l9J@5cFw1g`pF~3{ey%+5v-+HppunXjz00b{N?% z8dV_uai|L|s?mu(&+u{KS%lMMbmtrLz&Fh3;fG@4gf)?J2|a?OSSF=zs2(~hbbgqp zT}Y1&DR%5ila)+_p|jbA5ux@GSf;=gNvuAq%3v9l1L6Umn8DhWQtMl%M`0b3yc8u( zQ7={a+k2|VJW{f2aP6`(aDQE?W_>6whTU2Kak)`);O;NE>`zAeqmT^L$YZKnhRk@o zXsw(^(3dSgE1D+*?srj72 zltf!vUn{gb3*s5846AWoZ>!M5nlDxJ+j5_NWMEwkIfI)Zdps18^|0DAbd~7fvb$^s zzN5Q4=RYWDcq>@cEU{-Hy@E*jk4I*#uVM%k2!;<3v0O`wl2~nmc*SB5UM84pn8b6! z)6b3vszpp;Vk;QSnH4{pbEkiUm|koid}!3l5FRpc#|q_VPQ;IYb04mZkpPZ+&?^Y0 zvoc{jCYHXCb94T}hETBgia(Hvsn@zZO?j#|6}wNjf_|t?vpe zj5`3lz&XfkuB6~L9-l)hs$DjvjMI7+z3BPjY)M{8R6`?mp~EE~^wSwgyhiAX5wb5+ z2bEfnHTIV>lDV2_+2F1+Y76g{XG*^@`qcm$Gggx82HnisNnu*Yo%mxI;66!dtXl9p z%rAhZS1|Ss=z4WeX1KhR*~uBbQ&oK1$dE^9p(v2Z@{UhS$VfUmxMoVGA~DJHw-nLo zEh^yEmr3`6HZz~{_%X&+r#hlWgwOxuohbYM+w^;5#2C#OJr19a#}2Q%!P$te;MOpL zQ=_-2!IDFa$`CP1Y~?Q>Esv{3x`2IvLfy3UO~sh{DxU!bnD-5Zb|bv_KtpNMsQQXb z*N`}#b%-gSqs`SZ)$WO)QPerYC}*)EG`k2N(q0P-T@_u}#(^^9&<~^~33d@g1nD#I za($wHQ(9@T&ZU{QIg^-Me&YC=p4=;9K6!1BvM>^{ z2HIK@o`7fkG;@w`JjPPtA`Q6{3?ZCHIlAuW#VM5=+L5xTdU(s`1vFLYa-8hxh{#CM zZff!K^IqBU3KT@`H(;2+`HjpVZ~otAZX|~83o0+Z{RHsgr5beWc&81(pVz1v`jT%o zEn=~U&g`knamdhZkxwcfhcw*ok_ay0Z?4(TKbI zYW@77nuWsnf0eEt5TfLm+I7NgFGjK90>9EkO62jq;6~%58MjYBL809EB1o!cmu<&Z z5-bsslS3S$ zNIAupGi}l-EVojeg~^qv_NB)s##bd?NoT{alZ2395Lse zlO#g9^Zg1Z6Ch;p==90#A{#GN;4)=t*rWd1U|m=1&BMs4&@t<`;qm+~BSCr_p84#! zgJ+k_{~Odzqw@rbD-+x(y1uBwo*Mi(Frbs&3hCVbF2i@r75A_!6TXkrhb5e-FB$-R z*lwogs)|g37bZV2K`}Z}i1{7{>dGnXD@WB9VUqfkDITUqMGv2z= z82Vqccp`+L8F#Ir$X~<7SWw8J!Bcom(%vhIO=?-tAUWg5T~y{M4;QF?cIuNa%=Q4f zg2-hjmvn(i2@9*Sckt0}rc;YBSXLA!;z@!jl)(5Fe%LrLhj|o4AthF)ld3PXAA(6( z?>7kFIG*+?g0ftEi$AmKb~L|NDnIQ1Lb_mcx2$-P@Y#r0?gHVOI|~}Vfm{X_D#*A} zH!Kz;4=W>B)9jWNZMG!cHwA5fK7}$&d|NRLPq;7Ll`DWe<5DT#UJMq-5`cm3809z} z;JA{5*ZM)IwkK#|ZHG#W+Q0%kUcuiapKDd<3<)oZrfMOMR#0nAy7vcWQ-Tor$t_1^ zy*Z8}7cb219n!taL?ZpEdA-VE4}20XTr73Ym&B23-b|BUW;qH`w;1P=FW`P|hgWUE z?~;?;%%2pd>_K?d#9AZ8x}AAy)jUza3%}N&H;a3JkX!V3`7qQ)-J-zRt;P4bydDiFY$Tjp(o5{G@%ky93B#3~C(zI&rg{oFN zUI32bhv`=UqbX@psn-4+E5@{wLVun(;-NgJ`U0}K2Jo3JdWSQe5_-5du}eiYDN-(N zE8eu9`6WtAyZ#k%d7p=Qbfu5IhQ`+>%l%;S+eOft2mKRGY2J=dlzo6`N8#v2hp$?d z!l{vbvk`4l5sTboa9%=ImJJ&lJ0?#o=G3%A3aHevzb65Y-K4czW(l8$b=-ven>$B- z`$I5e3;&%mg&cuh1X^L@v*!t{DlPKyv(s*0#Xei5{;tV@Iq_l2&wVbjhn}>GsGP>+ zaG{6APs$Ca9=CU8&#p(^uxglr6^>C|g@q7}%YG&C;j}#=60?j z)rE_OZmJ>pl6Vz_-vOe*U7BmYuI`<4vF(q`=VNMHOy&gjTKv${peHl!HT7B;QRJ`% z-E>Er5{*tH8`>LfCUMq+-fnxE-5fS2Y|^<47Y1i0=xNMayhDEub{A-Viw8$N8nRdW zl7UN=;2&$YsJZH1D@dDv!j)``hm?PeiXpKhiivE)6*A#IRg$6=`f&n^Rx3 z#V&|=LoEAYl)wknO5frhe5>2aa+Od$;CHjrUF|CMg8I0zt;6Ca+jrqQ5AblE)){EE3zIj!up z?kSJNQ3m!Ys|RMv1-?CZ+j0_({serVJz*=>9f9TixKp-#2~|E({P^%C;CH8sdq1I6 z8dO>9vi!y)2vE!d2_cjAKaeJ*d5M-Sd_LtJG3ki}{&@-o9&y_2xWk=!|2xyXU;ZBe zLP5R0+i$b1G4$3uZ`1VYzhPA2!68WBO`1gSy!#HjjioO*PZ(^VHJ6u`(%bA8X$Aq~ zXI9>O{{yPKeXuvqE5&~Y~A2aE6fM6;pQmbYO+D;oitiWiyZ5?WZ zi#s5i5B{OiMf7Ez({N#nvlJ#@Wf~OeCdPiwG(sD5sZ@R(s0}m)tO3`lQ%6Wx`Y&fh zJ`2fLXMDJa5z?(uLx@-XI6x)y%`&6gOhN*V<&KWWi4W#d>*kH6uH>~~D?rnJT0$Fx z_37ScC$epx9R9+0b=^>a?&P0(z2ls72{d>0*4H%Q;bsKW%-sdM$ z1r^iJe=V_-jb)J_wcDvFKPnB+8>tm5DopQ^;*A)b;+V`t*Pu~D8a((U8ZzWm$}cJq zGR3*Wa`sck1a%6#oqX~sw0^?|TFfM?fuH+n-D)T5-Ln_9Y1NvJJ^C2=l9*_*++ol* z(1(hPOK9+kgXPO&bLY-uEp!uYEia{?r~k_KC(}tMoEz>1qFz0E(x$bWXs;f$}Qj=HYv!bKcy!v~#D< zoF05BESK80X-o6x&tnagQ%4^rEBH6xkQ>xrS$PGmTDyuimu{q=e*TeW&znPqB}KGw z>0%iyTDEFQHEY$Rd~S?gJ9ie1K+25e$TA2L&CnZ0qG<6yeD4|8Q~R7pJzyk!K z-zWn+bt-{vbvxw8NP{+yo)|Vd&UAn>6T|l+2}ueyBHMW8HyZAAskDWmj+f#kiG0z5 zq1_fU;TCb9D&W3}^S^M{N7B%~oyXIX0@7+5l)MsZaOjbo9P0ID_0BbMcWI)=43kNi!1Mq49wm zd(J59Ik6jS(6#xVwJo2I<}TYnufF>W%PHV?7NE_Wn0T3FRC1ZXIelm@ ziJ%E0BF`Ss?x+~OB!dbP^5JK+S-fM8DW4EE3_rsieM`bgD1}hGu!jgc0!1XYPsn8r z8GhjmTMUpWl9P@bOnY|kNy`^6rw=}Ohn6f}#P${)fAq1`W7lr9Z22;JZ|HlpVDSR3 z2X06#K-#oOQ?kTxUEqqJN2yo44%MkqllJM|hZ;9%Ao1jJnd*TDMg|2t|B^8Y&2l;4 zc(S>j?`=OR0OCZX7d-95aY%W`47@Esr#1C*Ne*`2MLh1M<)L7-rHxqeb35RARm&9e z7-^~sXn!zO8HWdAwUtaKBZ7QYwS5>;mYnz(B0eS(iul~2hHr zYXG%tV!BmG9XocUc5T|r`Ypce+o*A4TDW*IZ7C}wKPP0~f(2B{_cF+FePfD-wPvJs z&FVGs3(TSPrvV+7vza;&NR0iQWgr=mNlBcrwDKJXXoIR9B?)-vO3=zhBEXkAau_z@ zoQI9ukJBHblITK}uP(8G-sd*k3N)cROD~in zM?pa@73H(0>Br`qMgDw?UL5*6{WX6bYf3oskw+~W*Q85N-IEFerA&{A9GbH;nE+3c zSc8HV2)z{_*Qr74>E;Xfr&{y(zHhK@ zBTI*40!Ra8NKWjQpbOCrX&-RoE40GBd-s)1v*90(pkX71%ZJnP8*Jj284iSre=?=R zGl#*3+=vk$(%*mo&3VnGW{sQDie<}b)W}ghgV5SK>#9uB_$D%jDy-{34m4X3XHiKJ z&G_pN8a8qmee~%^Gb6^V$}cFSKWF|;!$*#wai4rbi{{Oz-B=4ofwpYkihi5^ z8%_LbqR5;4-6Wd$^;e?V4m@BWeLdl88Zl}#y*KOwTDWMD{LB!RGg`H3MQ?xbK7BZ5 zG|l+?FREF)HdO@tGN5#9-;st7A5No2jig_H{Z)Qi9)%>66*k_bm>4mNKR$Hb@h!qT z4Lf+4%t7K*w;_b%P7bO1PYODlvE)`dQVxsGnvMyTn$uJr^nenpZ&wzO8=7~c9ZEdh zYq#L1h;hpnp{O10Pq~6r)%SdZ6=k%wB1reWq`&9O#Abib7az*;@$>(k*qE+7yT8c8 z#>*Ku45!Jz23Ye&68zGs`*!ou>kkZO5@DfCs+-Gl>6EMAq@U*GlaGnj$}OeS4%>OoquK1fGj@-{75SA)}`j(Z_( zmksGiSD)U8^H@&hTnuMkGm^gf9lzrrNx<-{emZ_<%FQpJ{5)udJ{}}&e<&l6xLsL* zns#&fu(n#kWfb&R$XhQs)lgvtcA}rFil{G|cpkX~2nkTf!e2xd}OB=Ut zp(6ZjJ<5|AxvZj`{N;WssZm0oPaIDT>NlW0y6s7uSv$ps8|n%_RJmcxMygp*lOJJ0 zw0X-WA?y=`C|=B{s0hd}7h}B`m4w`E#aa-Ld4iR*3I%^BIFO+Z4rmbVY&E~l1+BrYt*VGFJF|f#w=Op z2=Rl561fvHBS{R;CAV;u2)y5#bj1g_qeR-g+mq-Sz)xaGMo|oBoF~DuOx)GHP$vo;Mw(U3kAymMf7++4q*4s00 zPGaIM;0DRW#ewsog)!lF>xAD)V=`tA%pNnRBUC(^e8=jhZ!O%@@QA)Gn#T&n!;F=E!nEy}I=zJmmWF zIXz77^=j9nhV|-8{CZOn`KVD`%rc9akaJ}4P~o^vxuFy@p*CySh-#ITs5gXI9^x)2 z$fE`<5Ao#t@~LKV4R+_jljRlWd0x}_=lP6`DY#ith zPQ#UnYK>*Q;N?#nwU(~(EY;73UGT>SBD8Nk5-mSV1TPR`19G`q#8e(EjN5cfQo}wA zj1+Dx6C-SzK!1uboIY7^*7uG+sFxQiL%aaU77CPJ^2;QUUQN8{YbyO|rZ}8+2ZSJf zOpF!u!*BCN>yytlvgzHcjcAnt{7NOaLTtD~%7ZUWrRD1}orZNjb?eZC&K%U838jo3?c7si#qch7Bd{-sz8>W>h#{&dB7;CGilW zbgKo(AqEx zgPPQ4%4O0Pf)U2W9W-ga_Y8Bw<6Nl%~XX@|EXsgBAsifHj$s@(*thxMoN;P7uTf@*l@&c)D@Em1|On5@Fi|c9V&;%m5%aBs>&FHkLgk(4|m4(g- zE{;rv@)))X+c_Y1l^p=wLOmeuE~Y=&BN0xMSY~XvWknlRpLV*o1881Q$>u93TV^gW z$bvQz51N1+nG{m=07Qd;D+0wd?wgrhK=?f|AJt^#^^_yJvKG9R?fkMV0N~rdBR^X} zQ)VnbWjx{#VDhk#4%UKNX55lS^fR9D4twpXrZ( zwsKM-SET`IDG6?-A%}J2fH>2L^S?Rt+2n;BPjO5QP_hYw%{~4Hc>7fL;^y$6gTrJR zNRLz?ms8?Kr&AY7r3c)8*x~s%JHp=Cd2K}bNdjsr90x2gaht1tY=cRPg474DY*5ZB%}{z%MvO|5 z%ClIbW#VH}9W(w631dM06}Lq8SJolj4ovI?x*u*O@E zM}zk3Kzr}mh)Gv{4GnTZyg?3o!STCLhn98Z`wiF%n7?`>J^9)XRDoCipzU(Qh40*) zE!2DGhSa-zbGGBqRt2rv;HR&qEFkh@EfdQbkqFfzXGIm3-3b!3%WVgt98?b5Nq%E> zys)(^BkUj@Aw$Jf6;NxrAUfM3RUm&XL$u+fWTX&LB7)8cQpEU;+u9}8tq$zp=7TjZn5Puq*IH3qHHFWM+RGilL` zMHTe+=wDbH^-&(Tz9L^P-FWE$s*_jF1dj8+ihMh@u;{Fl`%tbdjQgpa!$0!;*R+0X z9b4N){5bgUljCjIU(}ECbMsjS`((m>Z|u)hT3#fX3-W;A2C{^l;;QnmmSg0OuUIwl zGKt7p&hpCgi3SHiS;9bD7meMcKvRV<3wmA}vN)W)HpyX;op@P-TfFsSWi6#>n+)X5 z1IVVGWCckWKTDmk19sjV<&Jb=-$s(BlUBu$zJ#lKjD(#uoevnKF-Ea^0$#;C%g|M? zW#B#$39HR2oL-4mMx0Q~Ym87~Cj$H-@>ETd@;PIY_EZWh2B6WS?Ze=7lk|fO0x6V@ zS7eQiG)XpbV}T#%H7+2OFS!;@8x&A!FpnO4`5P+3*Bf(ibSXe54d}+2Xf3X0KjjD5 zhBJ)<%nugR({KMm^VV#ZxIndSo7SLHkMGMQTgtLQUPQNCau^l*DoB1CpY6Vxw17UF zw48zkkfZ)#%9a->r?ZdgOkFzEnrHR4}Rvf1j#2me#zu2R>i{JX$%Y) z?q(6UX~cCj*Z~lqe7SvKJgQ>UM1m(vHI{>GH=BfL&qv(QHm z-*^OTuuPV$G79wuidcgsT31?w?tAJBD)qCL8VphalWX(F^{F7gCIkzqwrbv(3JQ2+ z;xw(?>Zd!N`-1$wLe2!-al#?MnrMIy>RXfkbH?7RW%G!h%NjDx&#t)Qn63gGe%iLlO{m$Z1@1*-oe1KFyOBAQj80vZR`! zq$dl+hh~|U!h$d`5otz2xC_JUIn4q?T%2f~?&>bD$^z%rvx&@p=`3@yQbod23@3~4 zxUC5>*)~yN4Kv7^sr-5_PX~g*5_;^lUuez>_0ez4(VN#UpeOG*oZ2@H$~j!D0b>IR z9e=|2t7*(Pv(;;BN3S?dCEjStFx@) zjhv~%LspKD%MYVS2-5J?)rM@|i9g$dfm^qUhm_h^CiA?#L5RRMRe)`pd{xrW0M&Y+ zC$NwqAH9L^T;^FeOu(x*=h3zIeMFnF`~X5`w~kF~(HoB*PkVMPWAX05?pk5LQOU9jzeTgs$;q>SX=w+OU zW$y*{ zW+G+DawLigZ`!P_blF`)X;V2gTqe-G0%}>mm|njB7`o!D_EfWIt=b4F573eoK6?7~ z?^z#4Jeus0OpD6=LE2nl(W6g)Llvb=+&N{GPi54+(MG!ex`A}ZnNo%k%UiM#QTBcEK&*OTRZvL+(RimQ zl;jv?1ZydB7#4v#i7?{J1uY8~+l0z4WM|*#jIx2V_~UEHAE57J8C>`(BI7{Xln964 z$oA)i7Mnm^QjiTSU3hU2A}gJdM43qyPPuFln{49J9~d( zAn$yUWV!S1(P4y3IFh{F8uZoAo9XI%M$?vZ(6L|0wWJ`IETRWqhP0Ts zApN;3H`6&cjHEd$OL!niEt^$_%D0z)6@*~;A6`ceeLn->c7sQ!RWb;XmAXU$_7IuK z7#LIsaw;mKhY)Q8^2a?0)Q^9DW^8{2ZDkGgo)>?hkrN_6tP>@Ofm03zfqc<~gSqRe zch_3<;B`k)i+ZfN;#Y)m5+YcGa`JO&18cRT#!sUkep*8L<%{XQTQ8sjA2zccVO64bvkmK3t=r?czax21ea+9Pp3yh0_KoIc{0;{J~AE!f6nVFw(zM+t>c| z1}vWOPXpzJ8A1eDoP2VUBg2uxg8Tycr2u>nLd5wTbreIpbwVfA4bgxdZlPE86N?l- z+IyIFlCO0v-Ebz(FWPv`LGE$JA5UCVYC80OmLhas$w#)Q)9-Qzb z-%yRR3tW+ifW_n+;KbPEdoPpkhe?nxgvl>cDYYDBv$5UtR?hA7pau`Gwq6f)uDIqs{&TdVBOw z^z@t4Xj4TU=?z$8#?dk?IV3`=+2^2<*URQ(jgsKeUPRty8Bi`3myIpCBIjf?3%;PI5evR6pWqMShM&n+pyL`nf4H-cPRwyv12d|(1ya@!c1F=r!HSom}y z8ZT2NyIUYB92*l&E37@`_=+eOzn1A^O%{iteTDL~JY_*2ef`r?I_-+L>7mzV(B?pW zE<8C+j8I7tuZ9=Fh#mh&pZ64H_bh}2@vdFBR=!MDzg|80S(3bh00lP)KwWcgzb?4ZVXQ1732OFjd=c|Z2-R4aXE~fWZ`sRP||N=iLpb40a-zI z({ecdoDuv!oqUar>kmKU-LO$Z`N@{GYuEAsif;k4KLp|Mszg2DY-{im!a3 z48+X_MJ>gh!o3%gic(LwiB{QYJaTpDw{< zOEMnZ@-hc4IRerFQJAg*g=a;*SSVI^k%&BAxbcPX|j4;IqbKbF(rONY?~ z{~b!>C(ovJr93sawO{%Cq;A=wC2~g&;#*9K2?88NKnzA2j%)_vzxBC(`fp znQ()wL371nI*-#95gj!FR7yEYM`ee}7-XQzAq==FBfdBsNCe-|tx-}#$*~)f_NDK% zFnCc$k&T;hY;PfdT891y8)qYxIKBKyc$YuzCi0b?_V4vi$&gD3VOfAmk2d00jE+z?;)#53bBa04ZbI zkbYIgskQr_mqO$X&{-zal}TAHaF%k)D=>eL1a}krPURo#jCdUv-)g}0zRYjY-7o$? zBa&Kna03;`T3`;&o-vL4STnUsSxS)V739$FJJ+RNdp4!k&FfOTotlt*K9-Y%6m2RC z(#+Y5X!fFYH0kH1^vA3~#h?aEAM!Y@cpW*tg2FFlTz`INI{BcEtfAq@^Khzx zrE%Uc;K1O&g#<}%F({yXIE}2?ZrQR~48CI<0i+M%?8gY;I9&u31xJEIN)P84lWvGr z2W*+TQy3rIqC5sc^%9FR$kw`b>(uv)nT$2?Do!kP~^KalW&KV$X7h^vtA1j2cPy`&TFDo@H%g>=<*1TZs*1j0w! zp|Qjfkx_)+^b2V_8n{m;6=CvqzBL%mlG0ePF^ONyXw;~YvSw5T`6i2Gm-{r)Ru(7^ zL%xyFZ__2G{cd06n`X1I6XZ-+WZGkcwccER3470@Ad@iV;1iQrB0z$e?1BaI$}v7i z8I(;r{A^GGRpeq_nC%0#xIpT#wp_pg@Lgc_)*BLl1__F+c5vEJdHAUu5HAQuFVv^h zNw#@7bAxhDKX*~%3nM?jfc$=(3aOfooGS1M5g0FWgMJ$ke70bcyHRqa*t@TC#&uDBgESU*I8X;Sw?64UnY7EL3N3Njx zAu7oaU*kaM7jeSK%NRJT$40U!^%4!Ko&+XQ+|fX~Ic}H;fFL=~c3Qm*<_n>JTrf_p zSN&2g&z8jC9(Eu!pKPLP#7jTKxs3pR9RAV;idh#mn!VEiF{&XUoI{H=JC*~Hq4dW9 zoV7^?vU$mxn_OAAY>?U}o)9QA+C&4q!1W^y5s3!}u;Dm_FPxR(GTM(6o3@uYT*f0z zG@`>|-z1kCiPO0KL>kD52ZVRlmY0lpCN|L4nd3NfP_4c4r!7$q@`sgCVudUqm(x|dP976Gj=9-+=1}0`Jht$V|phrhxKO|GWTOZ4^KfAYc7^S6)au{YiVQe(0p3JIU2y zG74wAW#P^y86SyK6ww$f31?jK;&dZAoOk}B{V4+^%7f0Q_U9!hmPrh-70XU05nuuk z7Ia{u?(G^=K@b|JT74~GBE^^{|9Hd!U!ry48hOuG`&R*RFD=kE@~qw2;Oe}GoA|ZE zYImm}xevt^od#^qiQ(`87<=@_uu(JLPaWIW=R7KH7x73@y33NIh%gNNtWV786q1C0 zqJ=0ck*jXG_))gFs`xZGek3!_&|$z=NK@edv1l(meb^{Uu=dnMK9%PCE z`AAq8_K#Hv82cWrNBqMN8_03{$qgVL7&~5;=}ZICr(Sh{IF2<3TCkrA^ET6INAF6_ z8`R)Dv5Rc(g;J*O?Pp21%ZRx^aJ7>=|f!X$2lzr)G|h-T%`^3vn?Y6 zY$vkdhvn*De61{4l$h#(WYtv;sQ^Kh4E4M%LbrXHUFr13L%5Np29eF}2N5wM>QK8S zjJ%5BEC5GZQvvcI%X}WtdDKxjQB`?|*hHcwO_c|2 z!6hg^<({9HM@{P2p#AsmMEmaEgbL*sk-1~|@+dC{AL(%taSU*RN-N4})5eX|pn>a{ znFIJKeH}lRXaG#Hxf*EA+BI^_3NK|u#f5yhdJfjg-KQdQc;JDo*|TTUrp=pZmtA(D zynH>>C?G^?3M+e-X#)>-xKA39YrN5b%t&|SasB%B@>U>>vP>zlA#h1cUbjVnd;#Yw zRa{6tV|^WS&g6lCUh07VM>HQ7;tkSiz3r9~>^vcXoK}=c(DB-SY=qm7B43EG3pN6( zkVP4Z$R?LfobrcEgi)Kn;kXh3#jOVd{Mt?|q(r`$3UX2;1HphMVLw&yJ%0RBXrhl^9JI8_3w!Z0G{s9kL?U-c?8-oo<96-1Wf@mp_E;DlB9c`#z~ zt>3V&Z5f76Hh4t(5i-L6QGU-o_dGrN=p(dt-8w1bz4qFR9(d>h+Lehjmx2#unb~o4)Ua72vUpl@`NfyfTqfYrACHxMpfIHg@x3gM@V(}3xrhbo zFk}<rQRkwdMHOJ}O9@hRjV7XMKx{i$mla3Lh;f zX-NNJ*kef50r4Q2j-+a$MK!vrG6WPevWMAR3@-_Ry3%zDAxSH4;}ERc3vmKM z?Mzi0P%5!2PxWQwH#%KVoKOzVBV%eKE_npwylpqu>j7gRsG1Y+< z47vDW&0KsW8~+k%yc(P?A>U0KsD6Wbwq+QI)d?v_Hn-ezGrjxnyG*{1(!TrcCtBY# z&pt!1zxpbD_W5|)tw#?@ZbbM{7Sv#v!wsWJ!^ZUbYp>IxM;t06$kt7pDNtTP#kFg4 zUQ}fxKRT!?&oES3vb|V4+_>YxKZ*v{INux@(gAj;sPNO!4~EjJm84))*BG`RexLT{FqbLK*?5VwzLo zk6lHc8Y~DGM9WS>w45ot{ieKv3{@B%GH!!&$amaE0u9N&g;2M*ko;s?Heg32+lE;0 zMML7CSStkyt95Ogc*SE9a#?1wMXOh@ zq0wVT%S#9EFhQSm;z=}eA0zjVsUC)3{h^rKTw zJC&wPo=h7zY@~sQ93&lb#qt$2Z0JzFE~hSCy3kQaA5BFi#X`&@hK-rS*uoLo{%Z_4F9ED%V^~A z4{6ovRdo2E!>N7y_GqDwV#JMiM?zXbzRqQbOpq_q<%YJ)q&BRH7H~J4WSUX>IaFmA z3ePvV_sDiMGD5zH4F&1tmI-M2-UzSvdd026h1&p@5tAwzfpA=$Q9D&4cLH;3CD+S% zLXnf4+XPXu@#W@Go-dED`8;JW5R<1(ZnW`f<0}lAyu3(*ua$}6`;b4EAEyb)Ok_f` zkY+47_}E@)yAi~T-jYm@NdX~geDn3!Uo+{orvv)$&lWuG<4%AN=b$n#y67VMVaill zLte`ycJmE4(ix|pPJjLRCwDTC^V#&!{SQi8Tfb(V%se)3+CuBsZD99Y8bAJXdiR}o zIr%=CH+Mem-FI*L?z_oUqee}7?%C(*s6j_kMHx0pbLjbJo}FH=asZ<9zUe z2kCTcB{&V4YYlwedJI{NfDjE1l_n% zBl`H`PiXe+d7LILQ*NV)jKEdwA$Q!{CROOF^fKXyD+!jPn+>$EsCW_}2`}*h0opK9 z$rajrNZCjTL;yl|8XeY=fNc!nx=1d4UYcsAkyC-M(8Pe}LOjxB+sje|s@eAD+yK&9 zj7mHTz;<=HxS%vCAYOrK`BJcyKjQw}saR<$vGnZa@>vg*Hf{Wz4!q30(f{?=2EnBJA9=+)L z8?UFc&N_<*_8&man>VM|I6S^IHg(DrI`)`jY3{r^tc9+nP8~bbFVlaeHf`EcprV{U z88?ph*>7K}T`#=04e|{EndPyhthAJdj~GcSSFNHG2cJMKS~OSglH1rFecgK2daogu1Jd{dzvyX?$#H;}sY02Sq>-v+WM7awmK3emQ z0cpSp2a{>-7YX!kGt%#MWyaBb!_aAr56DDcOSIuuDa{vRwQ&Plt=mYtRgU=?8&f=W z$2}XIkJbP1BsQ|H>M-0yLvD_LEW!1UZFw}7<$yT&J3ct-!zX_j zt`KkNESolQ8di)B3|kl!9lRjD__U(mAK>!K<0hllQn7}MC7tpLKR<99efbpRG~v^W z0}dWY{ygS2?hAYG*H3)^^7C}51GG7$O6mvyab<(XGj;M*Aya7j_yy@2HEYsgg9gzQ zwnzS;9rx_rnLZK zI$VMrg>8^{b&$3k&O0`gV9PgRsdK1y>#_Ji^#c(h!nE$4;H-*I$2?ZyD(PqdIW?@u#1tPMtbb zyLN5y3wJE-pxq;iJUh)50sQi_iab^`2m}LigbnQuen4mi1^FCr1!|6oBFN8SaumYj zIIyXj&-Nb-8%B>k{x~gNzMRfH^GrIR{{ggV(n zVZ(-E?+pp4ZW-ab^daSB-*1bE#b4VFw)q5BBdV?}QYK$n7mcBjq>W6;OGfSR05n9s ze32Bevl3x20^4p00B?wE;D7;4QXA>bH{M`kWR2I4lM6(pTT1DXhaaXx4?C2KS>pvj zcEA4o3*nV|5ZsJE{*;gS?A)cRkfblqM-{ATil**L$1dG=r7u4JoRi49EQiO5go$5I zq}_Jgjr~TSUci!w{`kq3En8^G;>D7d|NZZOG;P{6nl$?p9Sm#sCr}~jR;>!#Vc)q17NBPXc9V3eV=g- zazWEURmPJt`AS-^1#~pthwQSXr6b0opDTFGQ5_fG}+M2p$;xbnG$5QHvHWxH@9gM;5DTdxfqh zJ=scOqP1NjOt_Jar8>K~GwxXFNf%7Ms6l1Wg`#z#Y5?a28A{eTJFcgDwL|v?^^_3o z58Cg^r<_6~M~$T4rvFBlUUCWD|G)#Z`yPAH*WXU0CQTbFk?ar422Vcy6n*;XIC|=- zC+Wzej-(e}dQn7SwdAFjU!=PA>e8suqs6WZYs9^J^^)+t`}UC|Ytw%Ef$qKUUc%2C zeL3MvYS0jG;#su+0sB$=_U-KCTU1n3NXM|oieqSPTJJ<-$Bvcdk?XFzj(+~-7aIS? zcxv6IHKGaU*S=yMyF>f-bi#?pQ{yI$2_JrkQFY=n_7X+%NUdF_Dt()V-9J-4g0oY_q1a1uKM>E$`z520VFZP1k^ipMt8!6QsY{4;D=7I$am`L}cS#xH~W*I8n z`IZdeZj7D-5PQM;ui7BB?UDtD)o;cjuo~pleQ5V6F5oA2n{qBotX{QRUa^*S>X=9- z&g2kTa+O2oQWXh&kVylY7b1$uKa~JJwVDdT?T*`R%7bZa#N5yWkZ;d&H88-S>N5S~mU8OAm#Z}29RXZ^I3BGzp2^qQe3Xf{QSh>dNeawMLQP2rs07?g2Ob9H%hZ<8pduoS%U%3%#(W@3 zjvB|UWDre}h;>=IjfRSuZS<7Csv&t$afRY$;BO+66xgFQaz_9?gHUA|j&p|ump^h} zo!SvfU)6xqBm-$IV)TZTml2=p!p~g0sDB&!HpBW0!qIR3M9^jG zEbyuU$ApS@80q7f(l||$|2Sl%L8Ec~!y9Xewy>a(wrtw0Y%`KiPCK_oB}v7v|EcFH zgd?BfxHAWlhW`?esw2D+S6o=6s!jaZ?k=+Mm+!mv&i0Q4BP15s;|V~=asp5I6=eB{M<{BsRks>4G}`wz^afOX zD00`(yRq)m0aXRO6$TQ)ku%A=D-TtPP!9PfmHdh}=}KotlP!cES-&V7)F;;eu)(r+ z?K)ZO#VggxraZLii}c&#tErkZ3Y2w5%e#_@tf{IdNEI9EQq62|pKhEk4oL#i^d(N! za5Pt_N7PEyQ0zb zqt%H1i4UBWRikKpz&-WUd8aH45C`HeuPCSW8#aiBFW(GiBOm`koQ||5PN$tptfMfz zYAbeXGJ|jmW3ybMTws1Aq{>Eq2_?WrG+%tRt%iI;68V6fOy);q*=-cR#F0(ts>V{- zwv5PHt~$*xb3nnSU$?4WRHZ~~zPO23VeUSW$bT3aCSUb77&4Fysdxk7xB&RCJ7Heu z5CbvDSHH9n8!Ddkkhnog%q$*jnc84Bg*3@hjh(jmX|%y|^uXigTbFfGvKUGEmpI{1 zH$>dVAzv)-*!R(30UQ)Bc}Uw}zw!ZlHa;GZ@G3K+NHX~;L#Hc=cNMD)&hB8yH`74j z#%_JdK%d4y`$WG`Z4ojP<=?AMBrY@Q2bm<_99ugBbO%ozsvsfNoiH!*)vq-=bAfau zj$g%-9ugNWDo|G>5W1H&POe;?J9j1%A>AZr0`bzy>55zC99EZ!%7h|fS;yxqY*HWkmQ!;MZTEIrDPkN|agT$A}Lgc%H0re#ihD*2nGk{RDx6|OL z4t!MJ36P~H`Re3HxZ!~vvY55XVn-rh@R;HAhSUI8*Y*9MKQOUA}U0GwFtq~Xb~S?EzvzPKz5{j zFX`TGI`JsuCBHI=PCXZ7Qe_d1KvL=7-l3Kl1R44mPjw)4w2_nU>ad}A}%KC!JYcsd}zXbrPn^o8xGT(>V&UAb;Q zYRI+(d40}l(yyR@<3N|ov!n=P+i!P3wQi4`7S%pIj3H|Qx?VQAAGB*jz8L_TvGZ+R znBuGOzT10KuA203-_(or ztLZuZ85pFG>Tf@4eK%7`KK-5>ZhG6u3G~rGUCh$7C8_akz~^=HP5N zavxdP`c#e6B!fz~M}v$5e3BIT#vtw;0`W#J4Iy0i5@D26%0M`1DQ@GI@8yGY?Si9`y;_6}q@LGgr;tiNR7B`a1v z<@oJ2U#D~I1II4FDn=cKtZi9NZz72;oDwmP$AS(U+ea8~Kgf4w+F5ul2$H!h6f5ToF zT4GfoA|e}A8DAvO1YtaXSJBZ?!cEof58P+pCJ7y_a*0*RPM~Q(Yy6DwR&T z5G1zY9E1?^rrjI?qkTL4X8uQG$OVpceu(54J>1MzB1Jfzy z#Z$ZX{CGj+CLeyjKB<^I${63Sa6%--9f@o=hpZH{nH0j+#W#^jtU5R|;!#!_?jmr) z%T3!Qv-IHUE16*1DWquZ0`ekXyXd^C!LyFM>ckD@9e?0Cxx&qvKYxB)pGy0cJ9*ZG zqXRjTXkQidlOBzgKj+^toIb*cl$Q#Uqzw>3SKv=l&`LU6+wceQ_fCY=7N0hfaz-?(s>PF2VW#4K>LA_3G&tH4b4q%229hLSAUd#u z4vwoLR3r?)P9Q&<^h7*2NQ*NJ9+d78>&%;@q$a1^T|n6@tULdr_&WdaG`*W6OQui0tacmnH83#PkpUh|7Vdqqw8__(oqVi*p?6o1jOh!()3$t0LT$%bDegX0`|YahuC~`zPcQG zj={@mpYz4pUgw6Jfq{WRHh~u)faS}V)B5%6g^0D1h|>v;8a1N2b?YYmGFUReTW^~- zZHgp%TQ7|uVh?3CZOzxfz`$Tz!)>?SMmz7kGwr(TuGGDIck0%yn;1NI=+J>)d+jwL z<#d(_G7Uq%1_lQI3jFiWKlI2WkI?`9-~Z9d6)R}nx^=W+!v--+moAmpg0H;tN_yjs zH^{1j<%eY8Bz;4^1_lQI3e1@^N0uJ````aky?XViq@+ZaM`7yJsUvH%&p-b>U3=}d z^ztjOWL^6;bGjVPCK{}i^sA?zQD3&%jmAV?xK++N79xpTV##q=%bI89m{6T zni=vnFfi~0c%)ssc67v`LG;L@k4BK@$tRyIN6miv=_fkuutVvE7hV+i+O=!Tazop; zZN(pyya^K~i1z#4ci+j0h^io8z>CYWa?Tf{(5h9dWc>w+LV@hD#~$?OpMT0?IX=!l zY0`I8uU`F-cB~VRXKkl}d)R5jau*wi9N44KD*I_T0|SHV1^O>E+A(9s(18aY80zo1 zo^r}5)UstuI`!03>7auSqN9#FiV64ydho#qY1F7up%0wCA3C(q4P*MboEGr(E`fk@3kVpA>l- zAC4SJefso~3N$b<_;&(*90WXk_;BGHa1S^`5n<#Exw?@R@?E%K0j*xWTBZXSt(@yD z_)x__Cd)Irb_UWge%A)$yPd`lyINT!EY(d1w9CM5iC&C8@gJ}%cyqp1=-@`78sREw{8vD zXGYS$1rDx2TAxjA;P-w^?;HFKR%11Ils2Z4F=#dM4a9W}<`}ymyzo=u!jr@QfhC!=v|bYR|n zLX_$cUGEwWVo`86uliyTZ(VF=b7eZ&rX` zIThbqjvxXUxf0>y*VQH$zr!NH`PPO>uQ41~alI9k#kb>02*=w!@&1gMaa-R@+)2tN zIokO4JiXRtJskR~sIQpj68j zB2a&ix#V<3S-D+qu zi45T@2dF|Bdo0mQ`Yc3XH+Y@B*rAksZ)IjsUT22V=(%Hbyp@2T7abu7t-sQDiq4?> z`l{^f3>c-m>((BQ*~~uP;AF1d*_~d~N9(y8BiWjY@zX%suotz}KGvx%l*W}DbV3(X zZe9uejvUU*`utf=PR@2TzxDZ8wuC-}^OVF*{>k}Sp<^e8gIPdHyrQ~trSTR-eEcXHqX`|xY z%CN!gO85G^Puofu6tw(S0qkwd zOL)x-D6LAb=3N-~k~uOEQsrAwW#}EtJBS7faV2x)2U{FZ1Tot8(>&vh$I~c6>Z(bqtJIz$t79$sc@C3WT7Trv)Xb_*VKnKYT@U!5t>k z%)`I&2Uh0Dz1{>vwNjox9*8%Y$}Y)PzI+0oA%!sVgYPqH+t}#@>)^V?o>L0PhF>$Y zD-D64zRaC}81KX z>`}o?9^Piqkv(sItoBqTN*h5qD3Q4m^46Xyp6`{xddiVuj9OSgOvHR6HK-+H~p z#!iI# zTJ-HJ*Q+1i4B8fL!n%(PpM>v4c)3WfB-pZ*xdqpn4*l|daIe2pJpHjwt-7UUaU#l5 zgW-*bWz;O{WV!94@YMh?EOM{feg&K43iLTI4UkUWX4!!;_eDiT{XS0#8r#7H(#MZD zISbZeL^$`^hZ+O_fDMM%a*}z@vM^KhrtD2q5@=rW6f{~8tFc|2tJNGQ*;vR_HZLSf z`vr&!*V5Uyql3~&0E;tE=d8-cSFGOYrb0}zz!?tUuNV@!e!9Lr&b}K5Bt+R}zIMdT zt9|m55-?$x^1pm-96a9Z1&k3wvTrCI9{Qn5q6A>Ud!jWF z2UXUyJcq>SuIaqnBEM%U@+`);W4!2>0NPFe>9z*D1ide}hPc`iul%8yli(_mW(7ET zrD9NygD|`WlfXyaR?HQEw<^BnNJ1Ut^M-gyAHS9nSg@BK7M=q{a4W?)CKm%nHOK#R zJaajM{c8>YSFKMmZ~}d~#$8DrbwmN2+>VpR&sCGSd2v4nw2k;MDt=}Ctg$uY;M41* zYDIj6AmpYR_X^mzPFrA|Y<*iTF^NN4_<7+7gjlGSmu}p zFr`U%!_ZO6gR70R_i|C>l7RFQjo@fl-H%Iv=Z1HvWs7?UL6*(`{{Ft&_XSe%0#b^r zu_;}j!;Q))Zc&--d)p9IL|+A_?nJUg#^1BmLkp>9#J}iFZy7EJ(T#pbtT6KZerTBc zlK0t%6T{cMri1sxHJSpOug*Zn=s(R>>uUfb#0x&ds0aDDSJm6Li;dxCVuqb)BIr0^ z3I;Jucol0Rh=G{tygQs2N!UI-NNYL!nI3{q^T=Uakm2|ngLDy~^phzQaJ%M1Igf)} zOqF?{l@$q?iim+m8v~G*OCWKrZqk=v zSH6a7Lu$j+zjt&hvY!Lwoo(or#+K(LZY0=lEnwZK^XEcY8vf4z{N) zc*VD$4u6M0@#iV@EgacqU26}~5e^X{E@T%g|7GgHT2sHKtt{*~mY4dy)QtsFx7Dg-nY;;6;dDtQ<|7vztUV$Z>85Xp|n%JidVaZfDjnM>`- zD-JT=8*f|=z-88(URWQbEYbw~u+`#R4bdp}SL#Si=nmLC>wb-8bA=Rgk;Z;~3FM_x zA*Wwq+0&&uI%kXT^x4LIl17;AxVIx0C7zhSE^&F90ojca`bEA2)Sg5^eUI%2UqBp( z)ap3bnc-_^r<5YswHKft%=om;C3m-HZMi&>f-K%Uwez+K2WsLM`QJ;lpM3De zC#V85$iIL@B_pxnLW;1<=R^tuj#?Gw3`>NUs8jjZmebb_Wkqat{3ZH!hxgS6(T?2h z6L`iCyU5AiP;5TyxB0x>9L;>p@+#6?y;g~LJjSi|ZH?truTk{OqlXVG-hL}$@csyr zmXo(A@2Gz45oGw=gYHVzT7k9Gj!Uta)#fkBHQ+-t93%;RBV=<7u4B0ajlU-Lu5bWM@hED0UPuf1+h-i;{)m`9~eB;QAB z;iR=ro<8amQD+v!Ix z^$NS%!RjO?w@01hQI+dKox4}9f8G&ALw*jppkX-EO}etA~>S?Xm! znMo+RV}^4*5Oj)tZZ5xtiAWUmx82<~-6!wIMECoI@#fsmNgC5fJq$+4i&v^{Bn^`< zUnR-lw0KMgMVSn8hQx(0*X(?YH|J>g90;UIl1}2#W-LT0&+yA8?eO4U_0y%b+|Z&< zQZK)U8JPD_>lVvCL(8fsZ3~@Dk8SG2uI)xOW{^P)a=qGjNk4`YQuyoAB#>-b{wRkw zpL)W=MGJI7R!c^~VA#{6P!PU|Fva7@8%`HcOY*mGTnD1x5r;f9&<&9t`aE!id$(g* zCSad#^fZ5-=aed^eYasVtMWSZlfo`lZualI+EL!+G+JCK ze9#~g@|_-U6}7`~4QE*~tULU#MI0&VW=j~hrnR$fs#_`^gcPN=}{nO_!O(1OJ?|; z+8GeyrhNZcW&jEO7$8P*f@0B`Mh20PzzjrrW{;RmLD})~JYNY_^6dM?*~!ps>-k&H z=Br_Ch{{%`>sylF(wf0QV&RqIc&qO?@POy0=RT!|RmzhwQ4L`Z9ISM`nf%Ue^!xYa zszE$m4F_$EO-uQ4`9$s8?~mL=3b1T=NeS^a^mXr6w!>_c3NsudA|f~7vO_VyLFAQT z2pd4s=Rb*ZoC;MPJyw%+Y~u$*SwN2n3x5UAIKEdi#$DTOcP;V9@l4Tu$yRM~9XtqC z@+jVMzjAJDk;ATS4F92L4ZIMFahb!RFXP`NCUXIo)&=JE#RZ0#sA8R6cp`XZ1N8iL zYWPBqiKRPaj&+V9j4a}wnMK0_>lf-#mFgGDja0UxjaopGuk4vJn>}!VQfba8Q%d+W z*VwN}ManbGo(LbbLrw49H-vG?3>*p#w`OhOxw*ua|K z5QT2r*r*BvkDT5RDWF;gUID0T1$yQV^fIh&6u)-?Qsrw=as^R;KYjkMvW#AwdVewCWU+xgl|j%NnP7(+z5MU|(xBG&d`zi<8K zQ8-P6Azxk2yo`3;g@E>GYgly5WK7y>ZFcZdSdCy2z$V0QmLu-9jmyRNf^#c)A)d6v z+}H-YZ>ZcFpm)K4Er`0RdWXu0RyUA5gMn4dgejJbzD_ZW{| zxsuMG&magLb2;o`Oe&4fxuL~iuG3YxpSD+H8JYRUJU$zi$*`gYJLjSU6T@goNTvDf8j!;#HLp(|6u6J z?yQB7a7m-)Z4I?~L$@>=4W8~Hp5Hp;8jY8DJD}%U~0+xc;_|>pXa3 z50;N>inTuQgdaH;YDQ#b_qd{bF}_Ul)DHt|^Q$^Y9)6{ve>}?dy?sDGR;- z{__aD1MmesE}Y8tpRj-cYFnA{KRKkuD?>s-;Ihopz@FI=C94Uyrpkw6K zCoE?$M2a4tLpLj-K2j{Hbg@V)7p_2%g%`PWG1*4+7JN{HICt`Yh9-Dx|8xyWQiE?g z$lv2=|BvQD8ux_&TSSG|Ak-AAQ1OpJ1ycO*pZL$^K>c?)=4s&41Zb#^aC%XzSD|Pp zRjy8RmU(-qD85_3rKvLN;fYOI%)sM{|M~X&XBAo5p3?-Rfbv&T;Ty}3qX?Wdk$0Rx z8dtz-(5^E3mMe{UaX{AE99HKl*{xG+oeMg6<~~qLdhX*RUSmD`ey-Y5IygAklCCsZ z*-MMnJ*a^f)FN!-;?T%5SJ5KSv1`m;fL9s?5jzgA1FGckbXOlv9x`eNI z%f+vBsbOI2CVSbPAt%PLmo>jv75n>Q!mUFL7(^m1m%pt}fGG3q4vL@i?x%M$K*xrC zan!XsN17&-`8| zU=-j!GR@1P*<;#SQMBPr`uD>F``Eb5AnFbZAlg!S=<}R z)yl@aE8C3F)~0AV8f33_Sd(*iMYR$`M(~Su>TG$QN30B5Ewzfh@fvvWV$`sP8bVp? z<2omT;z?fUicQ}_=X=eqlFIXd;qdptsNlhF?bNudo~>#-jag3w$;q^io#F)rx%HoR z9_g_xv0?zrq`tzpj8kA;%M|&|W35-?C?(%r_ZPGkUa`MR3GO5~hs-(j#bjD+Pkvf` z`KJHT#YYI-(Vqi!mX!u)V&UF<+J$v8f>3J5u&CE5tq5YD^?3O$pnVh;X8FvW=0XDG zg(VFtl3)f@V`AF%U28&F!!q6M2#D6QD^T5m6R%HDG-w;_cOM;B4H zKCtknQn-z`TwV2BJZfgF5b2>MNZ(D*`>*BzxZNfHGJaWavYvg;P@SMA9FD9Fq3rF3 zGC~^ss=;TZoAW+f4AwFq%X+-St+nH#x?()z`FMSt5szDdY~mp84ySK6A}DNFb}vprQfH7kmBy>xd& zm+5>ofUG^dJnW-0O;`rO3DUb6QMZ@@7jWr64J5ECWmUAo8Wf1_L21PV$tzt3Qkmg#R(U}dy`6$6GAy^QfV+5H}Q2{flqpFRO6nl7A5VYMLL z_M_|GeE2Cg>=4+Z@ij`U#XlW~O22*0Jc3RF0RCrP5WCZ=^RN?yLV^#|GQohZf3Qy%RY_=$l;=uQmyou zZEaEoeMo9B^L@EkqMty+X7@!U>m|^bwj`2oK>PSeS&^ZU0TRZ55X{bY5}%xnjyGJ* z(rePxhY%h#A<22PRLvLXrtqP@WIXO@xWV$tEh%0)lk__pjC_yEQ9V}q^P2y9LVL>~6k@mR1ko+8{z zv;({l)-wa&&;qPHr-N2lY0EQEKK5bzoh|w5kKzW5!%0Fhz#nIi4Aw83K_lqHq2|Y1 zk%S|mdenL8kSXl4ZbNk*N)?7r$)-RgvyZmWGD=D+?31L=r2|2UAwz2fA&S8gltMnR zB-k8;{GA1B-gF0UgyM46BL-nZu<*ftBqWLBJL>sHW0&iP$l311XMWwj9%R|TLJ&tD zhQE#~K`%ZNs0_f>!&ftwM;$KVd#n@&-V}aGSf!+!zTEWRzPwUXl{9d|Nn^uX)gOUM z1C2=*8Z3uDqEQILpfaWx3c@^Sbw>&wLTG5$-!#+|(-_DuCRYr2GtMB_T@ZFb71k`b zj}|jpX6-z9)830bkj*OmJ8)-BfPY++A{Ydjt^j%bmIZ=^%qdv$gL3z#-#9#ll-;gG z)F_lx1#pz_hx4fr=Cc@Be5RyFoOf)a)DNWUCTF`rwhf9Qh;tC=!MZR;`02(SjI~`8 zIrcSx2u=Gu98zskGO%h5hqx!Ost`$uUzc|=doS03kC3ntw3+RxI!Yc0so35KVv$Eo zZjg&+tk(mwH!;lBaBc+dm!zPsU#YZly!L;R{5w7Zfj0=I-0T(bfDVteh~R(T@)tQE3OC_Cn(byLF2-28lmGmqAhKS zH`gFrbx&p;-8zwhLsfk_S}5bHCmeM_zli_-j4G?sh*F_<6_WzAxupY=3*x z8hxj9s>Mo7U7!8amFDBoyjrcxWnM;#ews-qRI3tl`%zGw&oJR;PxN&ZcNZ?w9x9Dd z^(z7ePkw^1jFX^r0IEtsSt31Df~v%+b=-Q`OSJ)$0FE?lMVvP4uTa_O>7VapyyNdG z{ok_c-$zZtM&zrc%H+_jem8O;ei?6&$k`gS|xi%?uM~qr*R`qI<^h6lR**al5fDo zT;A{+Z4L^9K%8{P2M_qV2^1*p%Kq*zrX#~7)^W&bG#o2Z@R_*QlnehO zQR8DmDo+Iw)iC-Iay0XiESoDH0B0bdVBQ!uqJ-+%H0|ssxIk5=cyuYW;3m%IO))0; z7)^lVM~8*))|XLjBbVH{w!Iu&yuhRcXFhfEgFw_S4@=dU%Lk^V6NcX8w$IC5LBazc zS`>NN8BY~Q@UH*+tzmLHZmOZs#~LZ|tl5!21aYz14rNoN?5YC+(ZZHFM}-A9!kP6U z$Dbmo&n_u^PQ;S54tA@Z((JVwOhw2OV;_<6I%x zD8=+&T@Z^gH>$-&#V98Ways~->lEFb6FeoWz6B5L;a0kNjKOBF-fcfkyJP^4w^(Wt z74#1mwbCyoN{r*Y{e~si0sbIHw5D80TDHpxnRz1X=QhFdHUUgAM!W6+gWNcS5I11_ z4QYE0znIu}=-h^8`TGn%(PI;hy8xlVd2VLRe`G-9zC9xjm*$FWk?o?Z>7-_r5F3$) z+~rCrQ?NRU^=e@L-(kXbF-YkJ02GA);-$n0u3}Xvk^UoCu55zID;Y{=^!hE`q0#F- zN=acNF*T@j;?-v6&{M?cA-n(KVSa;gWwWN-fY0=!{$D8Ce|e0-e<>I;Q}O@L@cgRV z_TsYj+1fmjM6f&$HTz??9Y4#R_Vhs=4Hyg$48bA0fPc)bsQq2Qp&`wUW_34Kg5BH@ zz9Ml8AT}Ls_vshc!v%=U{>!ZX&#(AjP6UpH7 zJ*JvS^lT8Ie>10qco-LDvcNmc0|}}KTCW4iD-Hg6(x_kHq8yZ=CMuTjM3Hj`AcZRM zNl8btC6-(lT?rZWOHo*-1D;@(bQvUeHb&{Chhy&Pmu+;v?wjEGf)huF=P~rdau~z8 z)%Zgs;HUwhP966`4`45l8D#YU4n)X#%nQ-G*kfu`HTLnD5B_kAH?tsU`YC18pGTbL-=VT_yv$Ipym0%gDOw z*5&Bk>cfl=?5}#G`1Aq*`vvi6*;5SAm^7So<}qh7z5&EJ|Jr`e2x&GpYj;V?1Yw(*(eH9KmEV%yYflw|N}y zZTt1CQn31McdWkwz@{}enjRvqy9&TI>9J)&f1on{TN{6Avr;!7rE-Pr5aL1aN3($H zBXO~tZ7Fp`k8vtodhqJmGca(&Tj=z!4(*}HG4B_K$`Pd9fPKpVdbnfr3jI74wEOsw zl=S@g*GMomxPyp)KzkQUAOli{yl?vSjx!OR^`eoGlmje)9OUa#}<~d!x5pCK*y!-}}0iw4v zn6;%ZOiCzM@q<`U7F zbaHIYb^sP(2^M%EU{tCmiHV5{Zg`@|Sa)^+NKU%2WfwW602M*;yfXmRVQiEAXr0*@ zfSeH9Yy!~(2pALpeBY%>W~rUzW(UM65?eKZwRh+Qe1b`U!MSWGyk87W+>m&$0h1#S zGRU5Q`2zn&}h)tXip&sz+WJUHn(A?TlFtP6CRf3ENm!&A>cv!Z_U50aru%zob=Z{B?ZA?T+^cnu&@ieG9<^al{L z8{i|KfQN>i;+B#lC=8YZ7LXPA_#(S-2AxEx58eR)iOJ4%PQ%>Q1Xh1NtElraw|U_G zEaN}M>vGC-ZSwZAJx7H;t{?-~&I=BG;qJh`EC%ZCq?zR4xCa+VM{cvSX4nUCjAc5F z&Jx9tsDo39PNw6_OT@7Y6-!u9NO;0ft|PgKYm{c4?VHFNL96|RhL=7*t$ZLSFr@#? zu!6+`nV%=M%AN*I4LCT-twuA1NE70Ff-OP8g_6mFH@BUb_t&jNEIaXpqV>tyaq>ZCGsphT*H_grmX)9 ztY)RTWy}zHsi8k*f$sCAhYar_D+Wvpk+oikJF&1A z@*r|i{A$r~{a(Xe6NC$f4;8Y`NMN&%*f zx_~_|6oBO~ehDSZDkpF;UCv z$5)a%U$fC_K!Ys7v80Ygb!bE%Up?b)KJw%DL|)U3KlO?)kbjQwC?Jz3-Nb4^s|)Xz zydP&d1`o#8=ti1Ttaw$@SWQfZ2{^c416PFy?hTgVDyAtkupHzKLu_{%^;I&3zprXD zoJG=-T6NyDg;UPaVtAj8iu_6B_XWm?wkWY_!{B4h7a?9wLd``-b8gaQ8iFbwl5y?rmA09%cnL&Dm?lo55eN+t_SV^6MHe|}FDVqqY+oWHfTv6(3#Faa_|qd{Pp{wG9qKN2UuL`d@p9<-z=xwTlgEB zS1{A>YvD>a7!RPB&il4QF8}!6v0L>=CUeAZge9nnYbP;k5f{$eHbwhfJEU>6Z1FsS z_mev7ygTY3&?p>qWJ;RV@_%oNy?D!)PggE>8c{A9-YS@JMLouAy2K>#gUqS8^0WHh z{Esxk-WVG8?$b_OuKr(S(|QN^{QMtVuWaA%u6A)<3;LVCL5_fNB!*_8n6G$B7NV!C zV68>rp3*;k!Mu=pl-a(FGT94(RMAmcySCi+QdQOld6wuLE3h-;xhn{mfD^1?0TL3q zTeswqu1dorWAgD{JH8Lf0J;RQY^N_&6D3%e>4XsE`NxCoWz4jHP3_yw&<1G(F;p{~ zhoSvIdgQdS)Otk)H=sxA$xq|_JNQEwS=34njkAYqC!@~Y}E^BzF#KHahaPo0@6GFH=)eT6U{UqtBqBaT5wsZCIz_XCto3AOn z)Aeacl;CI62Rloc7bUqhE+Sp#PBy!}SM-v!+E5pTO|xEJLSBCcL#Ti8mNg)1$=%_mo0C;GBnSmToZ9fhj#s)4=wgT6#5{cZjn=7gkM!v6-?#T9>q>!7 znrIQHRp3XbEQa9ML-d0kYUlJjFL^_pvt7h z-DzDoL6xZ35&#dD9=v~X4b0Jc-8*?)GS2sFt|{(#hNbhB0lbIlcI)f?a?xTT=pOZTC5UEV(GK7M{9-1X-ao>}CGla_ zz6l_&$F!eJm_p^5OG4&3lQIzP+vr(x{kB)&5HSaGybgbk{pB)13(3)stmPb5BIvBy zgl-J-@O<29Y86crg_8sVC{R2@T75Q28i9J$!HeI1h*AwQzQ>DlN2W_qw183Gt z#mjs6RI%U)Oi+=At)sAVAoE@Gz3(fx78l&yqD zY1|XM@Q)U$XRtvY%j7F5F4SAoEfj3H;jkqIkUHm%xs9d@b^C{h|1?NrxKR`7H;{>aVa(_*!#n9X~~ z8dJeNh+t*$c4WY%n_f_gXwU$eke8usUvUg5RT z6srIysDeYJ&g=D2lj70@?pPbW`Hw?JGBf8q`PkY|`LTn|CeQZPadV74?~ m7UK zOa$Yhss~}BRy;8zj;Z{S{w(gE3h4p#4(>SSdih;f6iRP8vwglm=o{8`y7qx!2AIh` zSE^BgPvjWj)Y?W8ex6WjzP^5ECwpLJw8Z6|`qE)z1?i zJ(6zS?y*49ZWyGBdpN&h*UwG}k4E^#=>A|heP0R56ZMRfj%`M3i?#fzSczLU-jq^` z%zs{Ftq$TixFejJdZN2kozZ~82-t*%Vfl^@GE9fuu=>El%wFqGq+pPboA*CPv(R~u(Umyo!DSZlxp?U% zehE@Gh{5*C!R#H0tw8Y|v;+R|($%9!k1Df~YJjWgaufb}tMrlGjH#d3p^EZz8`JyCU zBn*T5i5h__g+GFRlzaZOBq%)}nvAIU?+uE)0x#%z4ZrHt#rT}BU;@Ak8WxEjh=qWR z;uJVC{ZIDtAIi%B#>sTmW`j4jWDiXZsJE?O!Fa^ZUS*?+8A|%_70c7oXQ&=& zD2P0#%V2++X}{)v>b&{&fdpVot9}#{&h28#Snv$`;g>x*`CJ&8rUblztQ2JMTUTAd z!GLvdNI3B^f#u|!(F;*)(9n&5lE(y#@9j4&6Uf}i_ABUmOl4puCo~^qHccWiE`j1V z1?uJ0Kp|`OP0(@3v%BKLtO$#K##q?3RO>O$g2zs&^RHC%&w^sBKVD_>Lj#pjZAfhJ zUY%H8mrE-uVv~@OvDy$~ST!?f(?%$20!J}Y8KlpfzI{?6+s*-yt76aSolk2(c|P$x z4OJ$^iUES7F;F_NN3*F9n#2Jz`^-L>X*n$6x>DFRuKs8eOu^-4wIlCpe&+`$mu&Wj^4mY;iLq&|6OyHy$2+(qMf@e}(E zZ5#?Jh9zcTd_!P*69*w(lJvRazG2~>fb9aCdv^<+DY_?r5tm|QrU1=oJADEoU8+g0 ztpks2@7|C-FzHo0tUm%M{`wH>PN|zJRqo{g8TvU`5fiiK*($(3!)XBuJ%i`=@PY$( zFi>|EKgA)Ncg~d?2A*U4-xv_+e7kd9IVyHwnd^*`Jj-4#OM|P8H<7U5A1Uzxf*2F zJ7T}C{}5^mqdN!B#B6I(4Ou-0ZiQ^1Tx$+R8!+W_t5HWi*WE$w-w9ja4*Gd;NDQ5L zk1`YKG66;)Htz*6Aya{TBrP3^Z|W(V&zkA{K2eDGS5}M1fFIP)4P1OhP&iNJvVJL* ziOk$Dfa+6J;F#utiVzt#eAO-Sk5`#f?Zu!{7+D2*sIdqnBc%Jx3sTd<00>$fEO&gA zG`jrCR|l%Du~ukZXADmO-^Il?%p^uHJY4A~*j{iNBUyFd{zMj>FKJOR%>>f4^ujwO zRqMAP%1q37dc`iYBMzRccuD*Q+3k)Pb_k-0lGWN4zN1gk%BLbs(Un^yPl~+iS8!0p zPsXJ;Zhre7{K>B_{2YG3ZRydGVPVnR+e4}tya4fJoRPozzuY9hi|6{Fn zRm}<9IQQ1S(>gr_R@0=!xjl0wJ+G_I9b3|~ zVVD@M%2K)J}zK|+$rzTtNs_d8=6J;7ZfJPsIJGub$ zNSI)X6(yQOAm&wk$vD_#nSwFfN?@P;@Pcr~ zoC<&VQ1TX)?OG#f6&OGh?OlSMx_W+#ae5O>1Nr;*U!O!$U9C(!$B1vcyHv_@5oFM8 z?NfUiSnc4YEImYW;N4vXoiTtaR!*0?8feolpAfv`Ff|Azv-<7}-|l(8DMy>|fd8!E z1|AKNhLB=e2&YCj%t9a$eLrt{!DcJZ1<84lMdc>r6pioxj9O}>=Yd5K&?II^LF_4E zT9ODvL!rv0=RlpE4Qy|ZHRawl%_&KDiN?5f%q!25PB}l)~`Qg%+W}_!~>5iaX=>1F{6NPiPOr}!3cD#?Ei{;11OF+ov z^}ckTmxbAe(WP4oXY%yhXCd0hD>M7PIlFhzrt$DElsn)+_s(3V1Ij=R$HPA+n3&K& zlbrbk+dvK9lt)xk7Irgg9fz8`#3UqS6EhXYCIr~&yS|m3)sL(DfN^X>J~tJ1k7?_@ z5@&9fm}kT+pyS;Jd7GdcLLz?b!%ncZa0Wpc5%r#bn$%RjJahBvdES9+l@JVj-X5RO zwBIUX*75N@f-&V$pe|p>a@6B~EDd6dvT=w*5($$fJBvPRrX)YsZ*YWtkX$b=%r3ym zYGt|sS;Dwae(AQ2ibt3*BKHoF^%?br&e!~taNk?6)TAa90cIK{xFENGGm~f-nd^)j zE=E@%#*u%==a;3g--qH@CRKnc4!HBiZ|J7VXIZ4Pn}P;GC$0;hRq&=AY#76JM$`d7|I7Oiut(C%QqK9Go(Q)Oz0 zZThW{z2;Ws6TAtepD1=1G;H_tUp)iOCE`Z+(SsIUlHbUrqo?{U8-~{B*chc6w72^o zkt!coi#1uu4go` zb)x9|f#kea-rrz>MAfeoAFXy{-^Zih(+=zy;C2Z9|x<{a)lU=h}; zkdqgKX`)r{wz}PD?`F-bLu%j_NrFGoU__Zz=y(R-x*9hzIM2nsubzXJi`@iaPz_%$ zL}c$4=DRoszj{QLo6Wr-w8=nM3S4@dxCt+o?`nO~dBKsSzYh~4GFnTW;U(wPV&!=0s0sh;l6Zca3g zM7c)K+4{SSjI&>Ri+%GOEp1#FIU9X9nvv@Ht~*Ta`v?E?qi7rsonP3- zrniH-`gNHh*yMOj66ftyeAaAlnp`3;n(rLnu>Lv)^#HVwKEi=ipNZPXD}YAf2FGre zQf|~bYBiL~cd&#nMpxI<)yw;JDCAT23;=vnjFEHKbgga3;7-7cG z9B>}*>{iA8e7VF)Lorm}R>teLlU`4&ryJHKY)gW+SK@Q?tqW*Gt2k0jns=&3-nHF zRT9Y%a2z`nmk4=AdvHy&?U6-pFgz4`;FLUkEa!#V+XK8?W)Pfhq)o>$y}zX0YFp~R zIx%Q;@f@+Aoq`pFjhiT?Tepw<-Hu6MCBP^(U_Du#!w1{#FiQS-Q)}vwknTrWA3dGJ zCcUbluXtOQ{p%Ic?y}G_vZqhG){<`mDSmE-(~z|glw7%mPoA86BU(E*vHm=XI`Z2* z{)Hki1T3DsrN_B-xW%GY!-IhMod3GztJ(@ zjvGx|hzf@v^5J#$Lrm{2eM`gXg5%}s_V*6!RTYo;I15wbb-|;Y9|lGg{G#FpUq^(6 zm#JX#;B^%VMrgMVG|(e|Uip4}uRSsPMWGFOh#~nvg1X_&wGenBMj_0PBU{F`LYB!KyVog4l2 z0aTcB;yLoQr>&5=R{-g}uFxS6c8I%ICoT8u;_Z$`fL(8$a;x89fZCUm?R=^KMc7+MRkcU$ zzXB3Ui*$nsN(x9xh$spu-JJr`CE*4H=~PmbPy_*KQMx;n?oR3MzH^=T{f&Fay?@*> z&N#zyzjtiyGy+?ywKN2 zO|B<<%e=4p*^sQTeiwAvj75uoK0Mk{^f`u06m#!tN9+{hV+5)Li;m6jfmcQ#PQ=l^ z9+t!$BK(s@lwr9a7kgbIdz0)8hC$I_2hStpg6>so;X3xrCw@!i(Q1m1!cN~Sp&~bv zWmZFQVUB549f60HYXI3QZgy@$s6A6S)h3+zix9bWWeb za5!By_O4|GWqWkk{1@ePS#3=?tdq2hXSQJ4@FxOq*W4yg; zBt|pbBCIKI>hLqkIkcC(dumI-mn*PymD6>QB)!9{afrUgunygQr;T;tcriJ?VxwHH zi&}oF;@0rL67mzz9w$p2S`|g2;MHB(Pae5F=N^9p3W;^%98!Yi5I=ArxS`_Qh=snZVYN^*~{ zJPzWz06TE5yBK87)Q%3OyuDxWRi~R!Z(FcTAt&-Lw?gZpzXHA48r;C=Dc=J5G?^qY-@=zhN+=I?@SS26x-pEd=< z5p)X9zlx#vas;QVEZ9z+>5n0tkthE(;hssr>)6r=U=pZ%luzYO{B8ha&u`JAC@-L> zrw`R(%efrdJ4}ZBI>2XEK@5#QNc#Q%3Rtc2Pb*OJnzp|3Sda`tFn4VZF7uFS@6LKz ziqmISMQx8O#psV4V&;cIdTTt8mp?JfM(&VcfsvEQWO zI1{85bhBpv`Rikkq-IPShjN-0lDO^EyH~tk|Dngw+2p>=^@L;ikr<&95j~`({*vc~ zflh6Z2PwAWxh{$Qab#4#_OpX)rw*Gw^Pz#ufLd?I{g;{z$d}GKQ<3%PUG+ecWNF*& z&$n>)_g!JOJaCeb^sjQ#5wk-up?1mxH;|NFy;LMwyz>(-WIFn%K}6{10e3lm+WOU7 zu#8U$K4U@)UGu+Kd9~<6o`i}1*^6}IC^yA2#|1>p>)!_n>nf-tedjba7Bls=T7NU& z+lHEt^rm}6Ano)ViTR8#rX;*1ep=R*I>-!?2+8q7=AYHs+t${0x>0_2)^B9l)QJB# z6U4GdYkxO-h;CIlF0_9EfkiTsPRtjqD$6capx4_P+TT4?7u7HeIyd?7Gy=>baFb)dyXF${5%n8U5YhvaA)-l2Y%hG7bO>7YRe^McY9 z%}gJW#dyyxzj$ms3Zhav6K75_VEBu`M?*6dKr-FAOS%S2QrDMT8e(RE%wDrA-Rwg~=`)2f4Y3#s&Dsx54B=!rHp> zs&3)Kk_WztN!^gS0pS_9%P@(4=S`VVyCdUv+{P&4R`2F;Cu0H0hDnI@jJgxiqVMhy ziKEv)M!NxF{rm}=m!u4;Yl~O~5kWs`2q!!fH-p$unHlAZ#~tMyauZG*->!#6MBR4J z)ECm&FGGjw5Ra4W9i4D2-K0aE{>=u^U*g?I(Zvn4xo?K5^hj_#>!m zO@?03ZAle9ANgZY@Gw7A8qJ&h!pRH5nfpPPmb7jKc;A?|Pj2SZ_LE>`Iw;&YE9dhW zxRjeP*16r9xgaOGEy94Ip|5(*p#GDS#FF9pS*N4AAqDE|dp6F9y4gnKu$ffGcAN){ z7t*&e79UwtW-_DBwp&(;nv8FYb+*(R9d1mRf$xL2ORIwaAwS34?%h1$i)o%e7hC9h zL;L8@LYLh%JLePLHiZvYBvEzo2$0G4;uaT$Zhx!xJZ*nHX2W{AIi*79J$r3^-}uVy z?E4EZnwy4nk~mg9_Tt~;-J~Hi1%K7T@sb>+U#z_5<>uuDHyLX(U~BZ|AS|LI5*{w} zhuaHEY3J^6(R3%3R>79zv(@`g@;|KO+~T9LFEMm+3V@J`ey&~@Cb{kxcXJ+mcB&b( zg1;r*e_l^}z?Jbt&-}p!b12T|Agh@I)Ta+SXzWp!t^*tkoZ1AEM^)T8&?b+hCzr5hT+ODJwO z83G=GhoiGPRB0_a*preHU~Am_$}sguwKjcvc_=^Sq=pboP?o)E!Fx+RQs<46*njrL z;Z7mqiT|~^By1{Cef$s|uYqVCpHC&Eym^hWStfH=vmR+I5etFWBW3^Sd)w(O#@#u$ zBE$|fEys#;xtJdUTv4gSFK|PcQ$wYM@M{`wU7{FP?vh8*CfLXZxXJzfIq~|M-TV_w z#E-TxJgLL)=yHDBwSe^Wk41d%J9l2|mv9sRXHdZ8J{&7bD&8mEk$2GAAsdZUxnqEX7<8(N5kzM8izNqmde_y7hs} z4kcbEK}1)~r{arN4wb)l2c^A~;R0_F#>`>M(XZLQdtu=|TXJL+mfPc?obP1uHBZuu zqkf-Ao+B8@f%SOX39Wn}N^ui0*pc!^EjMv$-IaA~aO=Tq5ZNGn94EN3rEpI(aBGL* z9`(Bb-}fu`shR_@_WeB+@_C5&lAlIWs#rg7Y6#V;c1^k_K+)kURj(k};8`DrgLU0r z#c}q7 zqj>Cd`&F#|@yT-JT;7u%PCf7&hoJB^~OVf@=PzN~|j8k*bVGW&)r8XK} zsgQh!CBha_^O4XJ{D%4`$~T?XRqoq9N8vZ{q~7GyUlNOB$P7Aw`E*|X$rO}iC)d5* zq}6!;t8RNp$n>UEOm$?cq$TYC>ZE?|&*vWap^>lHF+>vtr7RJ{PjNTd8~P6|3Y`;D z)xW*`d!1h4-v4wh|69&OkAugIfjrv(yQm)&>WX)F6xQOBeb0O>>>s7~F*m;wdI^;u z>Y-=*m`J0MQhOfH(rP5B9ykBYhr^&+IgU$DeFZferTOh>r)T@oiKU(Ye8llK*mtpVd8yNkW`k`ASSWKCGz0MTKPwud$X$-8o0yI z2+dvTh`(GqCpQczf1h4o;kG@y^BYxllv;jyxNuXepi=QVhWU1RA9-pcG$+)=S|`_w ziad+1o23-*r>X3|=yhundZauX8ark+ZMU+B?6nHg;?t%zF8RkbCiT=>;f5W=*22D= zPYV`sR+o6S$%G1b(YosxGSiq6RayQyq%C}A2^{B>z0g}KCjFP$QAb$)VT^-! z$+5%k)}aw;v6&A1qhAx(Q(y%f+_R}lQTB+nG zUtQ9jjh!G#`N|t4&`oJ7)JFUnEW>~Hlka|!<9z&`4pTF)h%qgn+0gQX5R$2m!z^~z zLkq6w0^dBhRFKY1fac@GdlvSj$%E=s@`U2jD3Z#bq1>aNiuH}xnJPy<{eBn^?}NbA z%04t3skur;Y2942zjGAP#b~VP80o2dCcFKY7T+tnS+kq~4c$kf_gFKr&>c-R#?{9d#Fr1f2qj>AZ$a5vTecxbcilng$&;;Qfm;0Ev z;C&4f6Z0q7ezi*Njk0`%{QWEuCIja2F|XH|n=qh0&369^ z#)B^Qv%fn{hGYba$Gt-s+Quzko@b=$^}N5_o3bA(NlqNBN>H&QC}D&?`ffzf5n21* za$hPKO*H_xq}OQGnwF)U-~Cen6I40 zPY$D|3pi*o>aOoT(_QsH**^iZcH4V$gRqU@+I^x$=pI}J_s*6EU9S9fWk5DE<>^(( z0z@MnfKH)%TjdtB`FJieMtv_$%)l5r<< zm>-9q>}!kwRCHhm!#k0+awGoSW4`i(@n^9�tt1)O-RIA?Hun#6tYVs6a1=2eyE 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 P0e; { + const dbRequest = indexedDB.open("notificationPreferences", 1); - var data = {}; - if (event.data) { - data = event.data.json(); - console.debug(data); - showNotif(event, data) - } else { - var evt = event; - console.debug("Fetching data text...") - fetch("/notifications/chrome/getdata", { - credentials: 'include' - }).then(function(r) { - console.debug(r); - return r.json(); - }).then(function(j) { - console.debug(j); - if (j == null) return; - showNotif(evt, j); - }); - } + dbRequest.onupgradeneeded = function(event) { + const db = event.target.result; + db.createObjectStore("preferences", { keyPath: "id" }); + }; + dbRequest.onsuccess = function(event) { + const db = event.target.result; + const transaction = db.transaction(["preferences"], "readonly"); + const store = transaction.objectStore("preferences"); + const request = store.get("silentNotification"); - showNotif = function(event, data) { - //replace with message data fetched from Ion API/DB based on logged in user's UID - var title = data.title || "Intranet Notification"; - var body = data.text || "Click here to view." - var icon = '/static/img/logos/touch/touch-icon192.png'; - var tag = data.url ? "url=" + data.url : (data.tag || 'ion-notification'); - - self.registration.showNotification(title, { - body: body, - icon: icon, - tag: tag - }); - } + request.onsuccess = function() { + if (request.result && request.result.silent) { + resolve(request.result.silent); + } else { + resolve(false); + } + }; -}); + request.onerror = function() { + resolve(false); + }; + }; -self.addEventListener('notificationclick', function(event) { - var tag = event.notification.tag; - console.log('Notification click: ', tag); + dbRequest.onerror = function() { + resolve(false); + }; + }); +} - event.notification.close(); +self.addEventListener("push", function(event) { + const data = event.data.json(); + let options = { + body: data.body, + icon: data.icon, + badge: data.badge, + data: { + url: data.data.url + }, + }; - tagUrl = '/?src=sw'; - var tags = tag.split("="); - if (tags[0] == "url") { - tagUrl = "/" + tags[1]; - if (tagUrl.indexOf("?") == -1) { - tagUrl += "?src=sw"; - } else { - tagUrl += "&src=sw"; - } - if (tagUrl.substring(0, 2) == "//") { - tagUrl = "/" + tagUrl.substring(2); - } - } + getSilentPreference().then(function (silent) { + options["silent"] = silent; + self.registration.showNotification(data.title, options).then((r) => {}); + }) +}); - console.log("tagUrl: ", tagUrl); +// Immediately replace any old service worker(s) +self.addEventListener("install", function (event) { + self.skipWaiting(); +}); +self.addEventListener("notificationclick", function(event) { + event.notification.close(); event.waitUntil( - clients.matchAll({ - type: 'window' - }) - .then(function(clientList) { - for (var i = 0; i < clientList.length; i++) { - var client = clientList[i]; - if (client.url === tagUrl && 'focus' in client) { - return client.focus(); - } - } - if (clients.openWindow) { - return clients.openWindow(tagUrl); - } - }) + // eslint-disable-next-line no-undef + clients.openWindow(event.notification.data.url) ); -}) \ No newline at end of file +}); + +// Update subscription details on server on expiration + +self.addEventListener("pushsubscriptionchange", function(event) { + event.waitUntil( + fetch("/api/notifications/webpush/update_subscription", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + "old_registration_id": event.oldSubscription.endpoint, + "registration_id": event.newSubscription.endpoint, + "p256dh": btoa( + String.fromCharCode.apply( + null, new Uint8Array(event.newSubscription.getKey("p256dh")) + ) + ), + "auth": btoa( + String.fromCharCode.apply( + null, new Uint8Array(event.newSubscription.getKey("auth")) + ) + ), + }) + }) + ); +}); diff --git a/intranet/templates/dashboard/admin.html b/intranet/templates/dashboard/admin.html index db2b9ec8f6b..57afb2b0461 100644 --- a/intranet/templates/dashboard/admin.html +++ b/intranet/templates/dashboard/admin.html @@ -23,6 +23,9 @@

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

Add Ion to your home screen (iOS)

+

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

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

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

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

Manage Push Notifications

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

Devices Sent

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

Sent Notifications

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

Post Push Notification

+
+ {% csrf_token %} + + {{ form.as_table }} +
+ +
+
+{% endblock %} diff --git a/intranet/templates/notifications/webpush_schedule.html b/intranet/templates/notifications/webpush_schedule.html new file mode 100644 index 00000000000..37e5ac667e7 --- /dev/null +++ b/intranet/templates/notifications/webpush_schedule.html @@ -0,0 +1,83 @@ +{% extends "page_with_nav.html" %} +{% load static %} +{% load pipeline %} +{% load dates %} + +{% block title %} + {{ block.super }} - WebPush Notifications Schedule +{% endblock %} + +{% block css %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block head %} + {% if dark_mode_enabled %} + {% stylesheet 'dark/base' %} + {% stylesheet 'dark/nav' %} + {% endif %} +{% endblock %} + +{% block main %} +
+

Push Notifications Schedule for {{ day }}

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

{{ key }}

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

Scheduled tasks

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

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

+
+{% endblock %} diff --git a/intranet/templates/preferences/preferences.html b/intranet/templates/preferences/preferences.html index a5e236c0bff..678cb6f8364 100644 --- a/intranet/templates/preferences/preferences.html +++ b/intranet/templates/preferences/preferences.html @@ -21,44 +21,349 @@ + {% endblock %} @@ -92,39 +397,21 @@

Bus Route (PM)


Notification Options

Change how you receive notifications from Intranet.

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

Dark Mode

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