Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Push notifications #1418

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ ignore_missing_imports = True

[mypy-stdnum.*]
ignore_missing_imports = True

[mypy-pywebpush.*]
ignore_missing_imports = True
13 changes: 13 additions & 0 deletions apps/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ def favicon():
)


# Service worker has to be served from the route so that it can take
# control of all pages, otherwise it only has control over files in
# /js.
@base.route("/serviceworker.js")
def serviceworker():
return send_from_directory(
os.path.join(app.root_path, "static/js"),
"serviceworker.js",
mimetype="application/javascript",
)


@base.route("/404")
def raise_404():
abort(404)
Expand Down Expand Up @@ -195,4 +207,5 @@ def deliveries():
from . import tasks_banking # noqa
from . import tasks_export # noqa
from . import tasks_videos # noqa
from ..notifications import tasks # noqa
from . import dev # noqa
11 changes: 11 additions & 0 deletions apps/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""
Notifications App

Push/SMS notifcations and management thereof
"""

from flask import Blueprint

notifications = Blueprint("notifications", __name__)

from . import views # noqa
103 changes: 103 additions & 0 deletions apps/notifications/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from sqlalchemy import and_
from main import db
from datetime import datetime, timedelta
from flask import current_app as app

from models import scheduled_task
from models.cfp import Proposal
from models.user import User
from models.volunteer.shift import Shift, ShiftEntry
from models.web_push import PushNotificationJob, enqueue_if_not_exists
from models.notifications import UserNotificationPreference
from pywebpush import webpush, WebPushException


def deliver_notification(job: PushNotificationJob):
"""Deliver a push notification from a PushNotificationJob.

The passed job will be mutated to reflect delivery state. A job which isn't
queued will be skipped over.
"""
if job.state != "queued":
return

try:
webpush(
subscription_info=job.target.subscription_info,
data=job.title,
vapid_private_key=app.config["WEBPUSH_PRIVATE_KEY"],
vapid_claims={
"sub": "mailto:[email protected]",
},
)

job.state = "delivered"
except WebPushException as err:
job.state = "failed"
job.error = err.message


@scheduled_task(minutes=1)
def send_queued_notifications():
jobs = PushNotificationJob.query.where(
PushNotificationJob.state == "queued"
and (PushNotificationJob.not_before is None or PushNotificationJob.not_before <= datetime.now())
).all()

for job in jobs:
deliver_notification(job)
db.session.add(job)

db.session.commit()


@scheduled_task(minutes=15)
def queue_content_notifications(time=None) -> None:
if time is None:
time = datetime.now()

users = User.query.join(
UserNotificationPreference,
User.notification_preferences.and_(UserNotificationPreference.favourited_content),
)

upcoming_content = Proposal.query.filter(
and_(Proposal.scheduled_time >= time, Proposal.scheduled_time <= time + timedelta(minutes=16))
).all()

for user in users:
user_favourites = [f.id for f in user.favourites]
favourites = [p for p in upcoming_content if p.id in user_favourites]
for proposal in favourites:
for target in user.web_push_targets:
enqueue_if_not_exists(
target=target,
related_to=f"favourite,user:{user.id},proposal:{proposal.id},target:{target.id}",
title=f"{proposal.title} is happening soon at {proposal.scheduled_venue.name}",
not_before=proposal.scheduled_time - timedelta(minutes=15),
)

db.session.commit()


@scheduled_task(minutes=15)
def queue_shift_notifications(time=None) -> None:
if time is None:
time = datetime.now()

upcoming_shifts: list[Shift] = Shift.query.filter(
and_(Shift.start >= time, Shift.start <= time + timedelta(minutes=16))
).all()

for shift in upcoming_shifts:
for user in shift.volunteers:
if user.notification_preferences.volunteer_shifts:
for target in user.web_push_targets:
enqueue_if_not_exists(
target=target,
related_to=f"shift_reminder,user:{user.id},shift:{shift.id},target:{target.id}",
title=f"Your {shift.role.name} shift is about to start, please go to {shift.venue.name}.",
not_before=shift.start - timedelta(minutes=15),
)

db.session.commit()
87 changes: 87 additions & 0 deletions apps/notifications/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from wtforms import SubmitField, BooleanField
from apps.common import json_response
from main import db
from flask import render_template, request, current_app as app, flash, redirect, url_for
from flask_login import current_user, login_required

from . import notifications
from ..common.forms import Form
from models.web_push import public_key, WebPushTarget, PushNotificationJob
from models.notifications import UserNotificationPreference


class PreferencesForm(Form):
volunteer_shifts = BooleanField("Volunteer shifts")
favourited_content = BooleanField("Favourited content")
announcements = BooleanField("Announcements")
save = SubmitField("Update preferences")


@notifications.route("/", methods=["GET", "POST"])
@login_required
def index():
preferences = UserNotificationPreference.query.filter_by(user=current_user).first()
if preferences is None:
preferences = UserNotificationPreference(user=current_user)

form = PreferencesForm(obj=preferences)
if form.validate_on_submit():
preferences.volunteer_shifts = form.volunteer_shifts.data
preferences.favourited_content = form.favourited_content.data
preferences.announcements = form.announcements.data
db.session.add(preferences)
db.session.commit()

return render_template(
"notifications/index.html", public_key=public_key(), form=form
)


@notifications.route("/test", methods=["POST"])
@login_required
def test():
if len(current_user.web_push_targets) == 0:
flash("You have no devices configured for push notifications.")
return redirect(url_for("notifications.index"))

for target in current_user.web_push_targets:
job = PushNotificationJob(
target=target,
title="This is a test notification.",
related_to="test_notification",
)
db.session.add(job)
db.session.commit()

flash("Your notifications should arrive shortly.")
return redirect(url_for("notifications.index"))


@notifications.route("/register", methods=["POST"])
@json_response
@login_required
def register():
payload = request.json

target = WebPushTarget.query.filter_by(
user=current_user, endpoint=payload["endpoint"]
).first()

if target is None:
app.logger.info("Creating new target")
target = WebPushTarget(
user=current_user,
endpoint=payload["endpoint"],
subscription_info=payload,
expires=payload.get("expires", None),
)

db.session.add(target)
db.session.commit()
else:
app.logger.info("Using existing target")

return {
"id": target.id,
"user_id": target.user_id,
}
7 changes: 7 additions & 0 deletions css/notifications.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.state {
display: none;
}

.state.visible {
display: block;
}
17 changes: 14 additions & 3 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,21 @@ const main_js = (cb) => pump(jsBuild('main.js'), cb),
line_up_js = (cb) => pump(jsBuild('line-up.js'), cb),
schedule_js = (cb) => pump(jsBuild('schedule.js'), cb),
volunteer_schedule_js = (cb) => pump(jsBuild('volunteer-schedule.js'), cb),
arrivals_js = (cb) => pump(jsBuild('arrivals.js'), cb);
arrivals_js = (cb) => pump(jsBuild('arrivals.js'), cb),
serviceworker_js = (cb) => pump(jsBuild('serviceworker.js'), cb),
notifications_js = (cb) => pump(jsBuild('notifications.js'), cb);


function js(cb) {
gulp.parallel(main_js, line_up_js, schedule_js, volunteer_schedule_js, arrivals_js)(cb);
gulp.parallel(
main_js,
line_up_js,
schedule_js,
volunteer_schedule_js,
arrivals_js,
serviceworker_js,
notifications_js
)(cb);
}

function css(cb) {
Expand All @@ -108,8 +118,9 @@ function css(cb) {
'css/receipt.scss',
'css/schedule.scss',
'css/volunteer_schedule.scss',
'css/notifications.scss',
'css/flask-admin.scss',
'css/dhtmlxscheduler_flat.css',
'css/dhtmlxscheduler_flat.css'
]),
gulpif(!production, sourcemaps.init()),
sass({ includePaths: ['../node_modules'] }).on('error', function (err) {
Expand Down
18 changes: 17 additions & 1 deletion js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,20 @@ $(() => {
return false;
});
});
});
});

async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
return
}

try {
await navigator.serviceWorker.register("/serviceworker.js", {
scope: "/",
});
} catch (error) {
console.error("Service worker registration failed:", error);
}
}

registerServiceWorker()
52 changes: 52 additions & 0 deletions js/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
function setState(state) {
document.querySelectorAll("#notification-state .state").forEach(el => el.classList.remove("visible"));
document.getElementById(`notification-error`).classList.remove("visible");
document.getElementById(`notification-state-${state}`).classList.add("visible");
}

function setError(message) {
let error = document.getElementById(`notification-error`);
error.innerText = message;
error.classList.add("visible");
}

async function enableNotifications(event) {
let vapid_key = document.querySelector("meta[name=vapid_key]").getAttribute("value");
let worker = await navigator.serviceWorker.ready;
let result = await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid_key,
});

let response = await fetch("/account/notifications/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(result.toJSON())
});

if (response.status == 200) {
setState("granted")
} else {
setError("There was a problem enabling push notifications. Please try again shortly.")
}
}

async function checkPermissions() {
let worker = await navigator.serviceWorker.ready;

if ("pushManager" in worker) {
let permissions = await worker.pushManager.permissionState({
userVisibleOnly: true,
});
setState(permissions);
} else {
setState("unsupported");
}
}

if ("serviceWorker" in navigator) {
checkPermissions();
document.getElementById("enable-notifications").addEventListener("click", enableNotifications);
}
12 changes: 11 additions & 1 deletion js/serviceworker.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,14 @@ registerRoute(
}),
],
}),
);
);

addEventListener("push", (event) => {
console.log("Push event received", event);
const message = event.data.text();
self.registration.showNotification(message);
});

addEventListener("notificationclick", (event) => {
self.clients.openWindow("http://localhost:2345");
});
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ def shell_imports():
from apps.volunteer import volunteer
from apps.volunteer.admin import volunteer_admin
from apps.volunteer.admin.notify import notify
from apps.notifications import notifications

app.register_blueprint(base)
app.register_blueprint(users)
Expand All @@ -329,6 +330,7 @@ def shell_imports():
app.register_blueprint(admin, url_prefix="/admin")
app.register_blueprint(volunteer, url_prefix="/volunteer")
app.register_blueprint(notify, url_prefix="/volunteer/admin/notify")
app.register_blueprint(notifications, url_prefix="/account/notifications")

volunteer_admin.init_app(app)

Expand Down
Loading
Loading