From 54ca89bafadb398f29c6f49ad247d6e488d36f1e Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 1 Apr 2024 21:36:59 +0000 Subject: [PATCH 01/15] Build and register service worker --- apps/base/__init__.py | 12 ++++++++++++ gulpfile.js | 14 +++++++++++--- js/main.js | 18 +++++++++++++++++- js/serviceworker.js | 2 +- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/base/__init__.py b/apps/base/__init__.py index 5bb8e54ae..9e5602404 100644 --- a/apps/base/__init__.py +++ b/apps/base/__init__.py @@ -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) diff --git a/gulpfile.js b/gulpfile.js index 4efc80a31..6d4cff1e0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -91,11 +91,19 @@ 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), 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 + )(cb); } function css(cb) { @@ -109,7 +117,7 @@ function css(cb) { 'css/schedule.scss', 'css/volunteer_schedule.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) { diff --git a/js/main.js b/js/main.js index a5f76165a..b6213e6e2 100644 --- a/js/main.js +++ b/js/main.js @@ -125,4 +125,20 @@ $(() => { return false; }); }); -}); \ No newline at end of file +}); + +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() diff --git a/js/serviceworker.js b/js/serviceworker.js index a838ef72f..0344139bd 100644 --- a/js/serviceworker.js +++ b/js/serviceworker.js @@ -68,4 +68,4 @@ registerRoute( }), ], }), -); \ No newline at end of file +); From 5aa12d361e5098de0ba4752eca741c10016e76c7 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 25 Mar 2024 13:11:20 +0000 Subject: [PATCH 02/15] Create a basic model for WebPush targets --- .../ee286bc4206a_create_webpush_target.py | 46 +++++++++++++++++++ models/user.py | 8 ++++ models/web_push.py | 22 +++++++++ 3 files changed, 76 insertions(+) create mode 100644 migrations/versions/ee286bc4206a_create_webpush_target.py create mode 100644 models/web_push.py diff --git a/migrations/versions/ee286bc4206a_create_webpush_target.py b/migrations/versions/ee286bc4206a_create_webpush_target.py new file mode 100644 index 000000000..93dd4312b --- /dev/null +++ b/migrations/versions/ee286bc4206a_create_webpush_target.py @@ -0,0 +1,46 @@ +"""create_webpush_target + +Revision ID: ee286bc4206a +Revises: 214878041f05 +Create Date: 2024-03-25 12:52:56.462206 + +""" + +# revision identifiers, used by Alembic. +revision = "ee286bc4206a" +down_revision = "214878041f05" + +from datetime import datetime + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table( + "web_push_target", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("endpoint", sa.String(), nullable=False), + sa.Column("expires", sa.DateTime(), nullable=True), + sa.Column("subscription_info", sa.JSON(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + name=op.f("fk_web_push_target_mapping_user_id_user_id"), + ), + ) + op.create_index(op.f("ix_web_push_target_user_id"), "web_push_target", ["user_id"]) + op.create_index( + op.f("ix_web_push_target_user_id_endpoint"), + "web_push_target", + ["user_id", "endpoint"], + ) + + +def downgrade(): + op.drop_index("ix_web_push_target_user_id") + op.drop_index("ix_web_push_target_user_id_endpoint") + op.drop_table("web_push_target") diff --git a/models/user.py b/models/user.py index 6fb4a9c73..815d9a061 100644 --- a/models/user.py +++ b/models/user.py @@ -22,6 +22,7 @@ from . import bucketise, BaseModel from .permission import UserPermission, Permission from .volunteer.shift import ShiftEntry +from .web_push import WebPushTarget CHECKIN_CODE_LEN = 16 checkin_code_re = r"[0-9a-zA-Z_-]{%s}" % CHECKIN_CODE_LEN @@ -290,6 +291,13 @@ class User(BaseModel, UserMixin): ) village = association_proxy("village_membership", "village") + web_push_targets = db.relationship( + "WebPushTarget", + cascade="all, delete-orphan", + back_populates="user", + primaryjoin="WebPushTarget.user_id == User.id", + ) + def __init__(self, email: str, name: str): self.email = email self.name = name diff --git a/models/web_push.py b/models/web_push.py new file mode 100644 index 000000000..32839054d --- /dev/null +++ b/models/web_push.py @@ -0,0 +1,22 @@ +from datetime import datetime +from main import db + +from . import BaseModel + + +class WebPushTarget(BaseModel): + __table_name__ = "web_push_target" + id = db.Column(db.Integer, primary_key=True) + endpoint = db.Column(db.String, nullable=False) + subscription_info = db.Column(db.JSON, nullable=False) + expires = db.Column(db.DateTime, nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User") + + def __init__(self, user, endpoint, subscription_info, expires=None): + self.user = user + self.endpoint = endpoint + self.subscription_info = subscription_info + self.expires = expires From b131d81cb9f810c1472a7245397ac1bcdcdbacae Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 1 Apr 2024 21:38:09 +0000 Subject: [PATCH 03/15] Bare bones notification support --- apps/notifications/__init__.py | 11 ++ apps/notifications/views.py | 43 +++++ css/notifications.scss | 7 + gulpfile.js | 5 +- js/notifications.js | 33 ++++ js/serviceworker.js | 10 ++ main.py | 2 + models/web_push.py | 17 ++ poetry.lock | 244 ++++++++++++++++++++++++++++- pyproject.toml | 1 + templates/notifications/index.html | 23 +++ 11 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/views.py create mode 100644 css/notifications.scss create mode 100644 js/notifications.js create mode 100644 templates/notifications/index.html diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 000000000..9904f7c68 --- /dev/null +++ b/apps/notifications/__init__.py @@ -0,0 +1,11 @@ +""" + Notifications App + + Push/SMS notifcations and management thereof +""" + +from flask import Blueprint + +notifications = Blueprint("notifications", __name__) + +from . import views # noqa diff --git a/apps/notifications/views.py b/apps/notifications/views.py new file mode 100644 index 000000000..eb8d6c823 --- /dev/null +++ b/apps/notifications/views.py @@ -0,0 +1,43 @@ +from apps.common import json_response +from main import db +from flask import render_template, request, current_app as app +from flask_login import current_user, login_required + +from . import notifications +from models.web_push import public_key, WebPushTarget + + +@notifications.route("/") +@login_required +def index(): + return render_template("notifications/index.html", public_key=public_key()) + + +@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, + } diff --git a/css/notifications.scss b/css/notifications.scss new file mode 100644 index 000000000..1e30a6f2f --- /dev/null +++ b/css/notifications.scss @@ -0,0 +1,7 @@ +.state { + display: none; +} + +.state.visible { + display: block; +} diff --git a/gulpfile.js b/gulpfile.js index 6d4cff1e0..7d71a996f 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -93,6 +93,7 @@ const main_js = (cb) => pump(jsBuild('main.js'), cb), volunteer_schedule_js = (cb) => pump(jsBuild('volunteer-schedule.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) { @@ -102,7 +103,8 @@ function js(cb) { schedule_js, volunteer_schedule_js, arrivals_js, - serviceworker_js + serviceworker_js, + notifications_js )(cb); } @@ -116,6 +118,7 @@ function css(cb) { 'css/receipt.scss', 'css/schedule.scss', 'css/volunteer_schedule.scss', + 'css/notifications.scss', 'css/flask-admin.scss', 'css/dhtmlxscheduler_flat.css' ]), diff --git a/js/notifications.js b/js/notifications.js new file mode 100644 index 000000000..e4454d656 --- /dev/null +++ b/js/notifications.js @@ -0,0 +1,33 @@ +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()) + }); +} + +async function checkPermissions() { + let worker = await navigator.serviceWorker.ready; + + if ("pushManager" in worker) { + let permissions = await worker.pushManager.permissionState(); + document.getElementById('notification-state').querySelectorAll('.state').forEach(el => { el.classList.add('visible') }) + document.getElementById(`notification-state-${permissions}`).classList.add('visible') + } else { + console.log("No push notification support."); + } +} + +if ("serviceWorker" in navigator) { + checkPermissions(); + document.getElementById("enable-notifications").addEventListener("click", enableNotifications); +} diff --git a/js/serviceworker.js b/js/serviceworker.js index 0344139bd..c1aa0492b 100644 --- a/js/serviceworker.js +++ b/js/serviceworker.js @@ -69,3 +69,13 @@ 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"); +}); diff --git a/main.py b/main.py index 10e8c02d4..5d105fb9f 100644 --- a/main.py +++ b/main.py @@ -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) @@ -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) diff --git a/models/web_push.py b/models/web_push.py index 32839054d..9c4970144 100644 --- a/models/web_push.py +++ b/models/web_push.py @@ -1,9 +1,26 @@ from datetime import datetime from main import db +from flask import current_app as app +from pywebpush import webpush from . import BaseModel +def public_key(): + return app.config["WEBPUSH_PUBLIC_KEY"] + + +def notify(target, message): + webpush( + subscription_info=target.subscription_info, + data=message, + vapid_private_key=app.config["WEBPUSH_PRIVATE_KEY"], + vapid_claims={ + "sub": "mailto:contact@emfcamp.org", + }, + ) + + class WebPushTarget(BaseModel): __table_name__ = "web_push_target" id = db.Column(db.Integer, primary_key=True) diff --git a/poetry.lock b/poetry.lock index 447ea6609..112af31a8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,114 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +[[package]] +name = "aiohttp" +version = "3.9.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + [[package]] name = "alembic" version = "1.13.1" @@ -1017,6 +1126,92 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + [[package]] name = "geoalchemy2" version = "0.14.7" @@ -1273,6 +1468,19 @@ setproctitle = ["setproctitle"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "http-ece" +version = "1.2.0" +description = "Encrypted Content Encoding for HTTP" +optional = false +python-versions = "*" +files = [ + {file = "http_ece-1.2.0.tar.gz", hash = "sha256:b5920f8efb8e1b5fb025713e3b36fda54336262010634b26dc1f98f85d1eb3de"}, +] + +[package.dependencies] +cryptography = ">=2.5" + [[package]] name = "hypothesis" version = "6.100.1" @@ -2528,6 +2736,19 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "py-vapid" +version = "1.9.0" +description = "Simple VAPID header generation library" +optional = false +python-versions = "*" +files = [ + {file = "py-vapid-1.9.0.tar.gz", hash = "sha256:0664ab7899742ef2b287397a4d461ef691ed0cc2f587205128d8cf617ffdb919"}, +] + +[package.dependencies] +cryptography = ">=2.5" + [[package]] name = "pyBarcode" version = "0.8b1" @@ -2747,6 +2968,27 @@ files = [ {file = "pytzdata-2020.1.tar.gz", hash = "sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540"}, ] +[[package]] +name = "pywebpush" +version = "2.0.0" +description = "WebPush publication library" +optional = false +python-versions = "*" +files = [ + {file = "pywebpush-2.0.0.tar.gz", hash = "sha256:03ccc3e975b60374b7634c495595616be523bf2c7da0d976e84fda9ac8c63301"}, +] + +[package.dependencies] +aiohttp = "*" +cryptography = ">=2.6.1" +http-ece = ">=1.1.0" +py-vapid = ">=1.7.0" +requests = ">=2.21.0" +six = ">=1.15.0" + +[package.extras] +dev = ["black", "mock", "pytest"] + [[package]] name = "pywin32" version = "306" @@ -4140,4 +4382,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "8515f521c792bf594fb49415770d1e4ab7bb267e61202dd68091bedf484bfafe" +content-hash = "2fe3c7e472c74dce5f8eacb5c79ced2302f9c15e9cab44a2ad06c1b8eca4341a" diff --git a/pyproject.toml b/pyproject.toml index f817b8131..7f5eeb601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ flask-mailman = "^0.3.0" python-stdnum = "^1.19" playwright = "^1.43.0" css-inline = "^0.14.0" +pywebpush = "^2.0.0" [tool.poetry.group.dev.dependencies] diff --git a/templates/notifications/index.html b/templates/notifications/index.html new file mode 100644 index 000000000..daf3d5fe0 --- /dev/null +++ b/templates/notifications/index.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Notifications{% endblock %} +{% block head %} + + +{% endblock %} +{% block body %} +

Notifications

+
+
loading
+
enabled
+
+ denied +
+
+ +
+
+

Test

+{% endblock %} +{% block foot %} + +{% endblock %} From 04b08e61d84a5cbc8f46b3a76736c7b075af6a53 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 11:41:07 +0000 Subject: [PATCH 04/15] Add notifications to the account page --- models/feature_flag.py | 1 + templates/account/main.html | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/models/feature_flag.py b/models/feature_flag.py index 0d53809a7..cfca9d871 100644 --- a/models/feature_flag.py +++ b/models/feature_flag.py @@ -21,6 +21,7 @@ "CFP_YOUTHWORKSHOPS_CLOSED", "CFP_PERFORMANCES_CLOSED", "CFP_INSTALLATIONS_CLOSED", + "NOTIFICATIONS", ] diff --git a/templates/account/main.html b/templates/account/main.html index 1b03e767f..f7028aa07 100644 --- a/templates/account/main.html +++ b/templates/account/main.html @@ -77,5 +77,21 @@

Transfer Tickets

{% endif %} - + + {% if feature_enabled("NOTIFICATIONS") %} +
+
+
+

Notifications

+
+
+

+ Manage notifications about content that you've favourited or upcoming volunteer shifts. +

+

Manage notifications

+
+
+
+ {% endif %} + {% endblock %} From e19c8594c1ff775a46d3f7972da425373ef9c0df Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 13:52:10 +0000 Subject: [PATCH 05/15] Track user notification preferences --- apps/notifications/tasks.py | 0 apps/notifications/views.py | 28 +++++++- ...ca608_add_user_notification_preferences.py | 34 ++++++++++ models/notifications.py | 18 +++++ models/user.py | 8 +++ templates/notifications/index.html | 68 ++++++++++++++++--- 6 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 apps/notifications/tasks.py create mode 100644 migrations/versions/1418c5eca608_add_user_notification_preferences.py create mode 100644 models/notifications.py diff --git a/apps/notifications/tasks.py b/apps/notifications/tasks.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/notifications/views.py b/apps/notifications/views.py index eb8d6c823..148ac7ffc 100644 --- a/apps/notifications/views.py +++ b/apps/notifications/views.py @@ -1,16 +1,40 @@ +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 from flask_login import current_user, login_required from . import notifications +from ..common.forms import Form from models.web_push import public_key, WebPushTarget +from models.notifications import UserNotificationPreference -@notifications.route("/") +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(): - return render_template("notifications/index.html", public_key=public_key()) + 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("/register", methods=["POST"]) diff --git a/migrations/versions/1418c5eca608_add_user_notification_preferences.py b/migrations/versions/1418c5eca608_add_user_notification_preferences.py new file mode 100644 index 000000000..9aa9e65f9 --- /dev/null +++ b/migrations/versions/1418c5eca608_add_user_notification_preferences.py @@ -0,0 +1,34 @@ +"""add_user_notification_preferences + +Revision ID: 1418c5eca608 +Revises: ee286bc4206a +Create Date: 2024-04-15 12:54:53.073931 + +""" + +# revision identifiers, used by Alembic. +revision = "1418c5eca608" +down_revision = "ee286bc4206a" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table( + "user_notification_preference", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("volunteer_shifts", sa.Boolean(), nullable=False), + sa.Column("favourited_content", sa.Boolean(), nullable=False), + sa.Column("announcements", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_notification_preference")), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], name="fk_user_notification_preference_user" + ), + ) + + +def downgrade(): + op.drop_table("user_notification_preference") diff --git a/models/notifications.py b/models/notifications.py new file mode 100644 index 000000000..5ceea6991 --- /dev/null +++ b/models/notifications.py @@ -0,0 +1,18 @@ +from main import db +from models import BaseModel +from datetime import datetime + + +class UserNotificationPreference(BaseModel): + __table_name__ = "user_notification_preference" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + volunteer_shifts = db.Column(db.Boolean, default=False, nullable=False) + favourited_content = db.Column(db.Boolean, default=False, nullable=False) + announcements = db.Column(db.Boolean, default=False, nullable=False) + + user = db.relationship("User") + + def __init__(self, user): + self.user = user diff --git a/models/user.py b/models/user.py index 815d9a061..3a3630bcf 100644 --- a/models/user.py +++ b/models/user.py @@ -23,6 +23,7 @@ from .permission import UserPermission, Permission from .volunteer.shift import ShiftEntry from .web_push import WebPushTarget +from .notifications import UserNotificationPreference CHECKIN_CODE_LEN = 16 checkin_code_re = r"[0-9a-zA-Z_-]{%s}" % CHECKIN_CODE_LEN @@ -291,6 +292,13 @@ class User(BaseModel, UserMixin): ) village = association_proxy("village_membership", "village") + notification_preferences = db.relationship( + "UserNotificationPreference", + uselist=False, + cascade="all, delete-orphan", + back_populates="user", + primaryjoin="UserNotificationPreference.user_id == User.id", + ) web_push_targets = db.relationship( "WebPushTarget", cascade="all, delete-orphan", diff --git a/templates/notifications/index.html b/templates/notifications/index.html index daf3d5fe0..246567a56 100644 --- a/templates/notifications/index.html +++ b/templates/notifications/index.html @@ -1,3 +1,4 @@ +{% from "_formhelpers.html" import render_field %} {% extends "base.html" %} {% block title %}Notifications{% endblock %} {% block head %} @@ -6,17 +7,68 @@ {% endblock %} {% block body %}

Notifications

-
-
loading
-
enabled
-
- denied +
+
+
+
+

Push Notifications

+
+
+
+

You have enabled push notifications on this device.

+

To disable them look in your browser settings.

+
+
+

You have denied access to send push notifications to this device.

+

To enable push notifications look in your browser settings.

+
+
+

This device does not support push notifications.

+

If you're using an Apple mobile device such as an iPhone please install this website to your home screen via the share menu.

+
+
+ +
+
+
-
- +
+
+
+

Preferences

+
+
+
+ {{ form.hidden_tag() }} +
+
+ +

A notification 15 minutes before a volunteer shift you have signed up for starts.

+
+
+ +

A notification 15 minutes before content you have favourited starts.

+
+
+ +

General event announcements

+
+ {{ form.save(class="btn btn-primary") }} +
+
+
+
-

Test

{% endblock %} {% block foot %} From 683758823bcbe56608986909de6eb42965acea4d Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 19:44:41 +0000 Subject: [PATCH 06/15] Formatting --- js/notifications.js | 48 +++++++++++++++++++++++---------------------- js/serviceworker.js | 2 +- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/js/notifications.js b/js/notifications.js index e4454d656..0c11d263d 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -1,33 +1,35 @@ 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 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()) - }); + let response = await fetch("/account/notifications/register", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(result.toJSON()) + }); } async function checkPermissions() { - let worker = await navigator.serviceWorker.ready; + let worker = await navigator.serviceWorker.ready; - if ("pushManager" in worker) { - let permissions = await worker.pushManager.permissionState(); - document.getElementById('notification-state').querySelectorAll('.state').forEach(el => { el.classList.add('visible') }) - document.getElementById(`notification-state-${permissions}`).classList.add('visible') - } else { - console.log("No push notification support."); - } + if ("pushManager" in worker) { + let permissions = await worker.pushManager.permissionState({ + userVisibleOnly: true, + }); + document.getElementById(`notification-state-${permissions}`).classList.add('visible') + } else { + document.getElementById(`notification-state-unsupported`).classList.add('visible'); + console.log("No push notification support."); + } } if ("serviceWorker" in navigator) { - checkPermissions(); - document.getElementById("enable-notifications").addEventListener("click", enableNotifications); + checkPermissions(); + document.getElementById("enable-notifications").addEventListener("click", enableNotifications); } diff --git a/js/serviceworker.js b/js/serviceworker.js index c1aa0492b..80ea0042e 100644 --- a/js/serviceworker.js +++ b/js/serviceworker.js @@ -72,7 +72,7 @@ registerRoute( addEventListener("push", (event) => { console.log("Push event received", event); - const message = event.data.text() + const message = event.data.text(); self.registration.showNotification(message); }); From 9e386c8e2c2b5f9ae500d911ff572f16237d44a6 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 19:50:53 +0000 Subject: [PATCH 07/15] Button to send a push notification --- apps/notifications/views.py | 18 ++++++++++++++++-- templates/notifications/index.html | 3 +++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/notifications/views.py b/apps/notifications/views.py index 148ac7ffc..3a235f679 100644 --- a/apps/notifications/views.py +++ b/apps/notifications/views.py @@ -1,12 +1,12 @@ 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 +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 +from models.web_push import public_key, WebPushTarget, notify from models.notifications import UserNotificationPreference @@ -37,6 +37,20 @@ def index(): ) +@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: + notify(target, "This is a test notification.") + + flash("Test notifications have been sent.") + return redirect(url_for("notifications.index")) + + @notifications.route("/register", methods=["POST"]) @json_response @login_required diff --git a/templates/notifications/index.html b/templates/notifications/index.html index 246567a56..147d727c9 100644 --- a/templates/notifications/index.html +++ b/templates/notifications/index.html @@ -17,6 +17,9 @@

Push Notifications

You have enabled push notifications on this device.

To disable them look in your browser settings.

+
+ +

You have denied access to send push notifications to this device.

From 6784fbb5e1d1eb0d3c147be63ef252c8b4b0ee1b Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 21:41:13 +0000 Subject: [PATCH 08/15] Allow queuing push notifications --- apps/notifications/jobs.py | 49 +++++++++++++++++++ apps/notifications/views.py | 12 +++-- ...825d16072a_create_push_notification_job.py | 43 ++++++++++++++++ models/web_push.py | 37 ++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 apps/notifications/jobs.py create mode 100644 migrations/versions/cd825d16072a_create_push_notification_job.py diff --git a/apps/notifications/jobs.py b/apps/notifications/jobs.py new file mode 100644 index 000000000..645fd2a34 --- /dev/null +++ b/apps/notifications/jobs.py @@ -0,0 +1,49 @@ +from main import db +from datetime import datetime +from flask import current_app as app + +from models import scheduled_task +from models.web_push import PushNotificationJob +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:contact@emfcamp.org", + }, + ) + + 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() diff --git a/apps/notifications/views.py b/apps/notifications/views.py index 3a235f679..c0d28e70b 100644 --- a/apps/notifications/views.py +++ b/apps/notifications/views.py @@ -6,7 +6,7 @@ from . import notifications from ..common.forms import Form -from models.web_push import public_key, WebPushTarget, notify +from models.web_push import public_key, WebPushTarget, PushNotificationJob from models.notifications import UserNotificationPreference @@ -45,9 +45,15 @@ def test(): return redirect(url_for("notifications.index")) for target in current_user.web_push_targets: - notify(target, "This is a test notification.") + job = PushNotificationJob( + target=target, + title="This is a test notification.", + related_to="test_notification", + ) + db.session.add(job) + db.session.commit() - flash("Test notifications have been sent.") + flash("Your notifications should arrive shortly.") return redirect(url_for("notifications.index")) diff --git a/migrations/versions/cd825d16072a_create_push_notification_job.py b/migrations/versions/cd825d16072a_create_push_notification_job.py new file mode 100644 index 000000000..a00cde5b7 --- /dev/null +++ b/migrations/versions/cd825d16072a_create_push_notification_job.py @@ -0,0 +1,43 @@ +"""create_push_notification_job + +Revision ID: cd825d16072a +Revises: 1418c5eca608 +Create Date: 2024-04-15 20:10:24.975271 + +""" + +# revision identifiers, used by Alembic. +revision = "cd825d16072a" +down_revision = "1418c5eca608" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "push_notification_job", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("target_id", sa.Integer(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("state", sa.String(), nullable=False), + sa.Column("not_before", sa.DateTime(), nullable=True), + sa.Column("related_to", sa.String(), nullable=True), + sa.Column("title", sa.String(), nullable=False), + sa.Column("body", sa.String(), nullable=True), + sa.Column("error", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_push_notification_job")), + sa.ForeignKeyConstraint( + ["target_id"], + ["web_push_target.id"], + name=op.f("fk_push_notification_job_target_id_web_push_target_id"), + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("push_notification_job") + # ### end Alembic commands ### diff --git a/models/web_push.py b/models/web_push.py index 9c4970144..78d407c91 100644 --- a/models/web_push.py +++ b/models/web_push.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Literal from main import db from flask import current_app as app from pywebpush import webpush @@ -31,9 +32,45 @@ class WebPushTarget(BaseModel): created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) user = db.relationship("User") + jobs = db.relationship( + "PushNotificationJob", + backref="target", + cascade="all, delete-orphan", + ) def __init__(self, user, endpoint, subscription_info, expires=None): self.user = user self.endpoint = endpoint self.subscription_info = subscription_info self.expires = expires + + +class PushNotificationJob(BaseModel): + __table_name__ = "web_push_notification_job" + id: int = db.Column(db.Integer, primary_key=True) + target_id: int = db.Column( + db.Integer, db.ForeignKey("web_push_target.id"), nullable=False + ) + created: datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + state: Literal["queued", "delivered", "failed"] = db.Column( + db.String, default="queued", nullable=False + ) + not_before: datetime | None = db.Column(db.DateTime, nullable=True) + related_to: str | None = db.Column(db.String, nullable=True) + title: str = db.Column(db.String, nullable=False) + body: str | None = db.Column(db.String, nullable=True) + error: str | None = db.Column(db.String, nullable=True) + + def __init__( + self, + target: WebPushTarget, + title: str, + body: str | None = None, + related_to: str | None = None, + not_before: datetime | None = None, + ) -> None: + self.target = target + self.title = title + self.body = body + self.related_to = related_to + self.not_before = not_before From c63e5d1f959948e7bf009cc00bce3e94fcf30688 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 21:56:25 +0000 Subject: [PATCH 09/15] Tidy up notifications Javascript --- js/notifications.js | 23 ++++++++++++++++++++--- templates/notifications/index.html | 1 + 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/js/notifications.js b/js/notifications.js index 0c11d263d..6118f3a65 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -1,3 +1,15 @@ +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; @@ -13,6 +25,12 @@ async function enableNotifications(event) { }, 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() { @@ -22,10 +40,9 @@ async function checkPermissions() { let permissions = await worker.pushManager.permissionState({ userVisibleOnly: true, }); - document.getElementById(`notification-state-${permissions}`).classList.add('visible') + setState(permissions); } else { - document.getElementById(`notification-state-unsupported`).classList.add('visible'); - console.log("No push notification support."); + setState("unsupported"); } } diff --git a/templates/notifications/index.html b/templates/notifications/index.html index 147d727c9..37ed83ea5 100644 --- a/templates/notifications/index.html +++ b/templates/notifications/index.html @@ -14,6 +14,7 @@

Notifications

Push Notifications

+

You have enabled push notifications on this device.

To disable them look in your browser settings.

From c03ea475a8dd5b4424fb6f5608c8e08490d93b6f Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 22:02:56 +0000 Subject: [PATCH 10/15] Hush, flake8 --- models/user.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/models/user.py b/models/user.py index 3a3630bcf..fba41d1f1 100644 --- a/models/user.py +++ b/models/user.py @@ -22,8 +22,6 @@ from . import bucketise, BaseModel from .permission import UserPermission, Permission from .volunteer.shift import ShiftEntry -from .web_push import WebPushTarget -from .notifications import UserNotificationPreference CHECKIN_CODE_LEN = 16 checkin_code_re = r"[0-9a-zA-Z_-]{%s}" % CHECKIN_CODE_LEN From 939fc5f6364bc102a95c42162f62d139e4efc32f Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 15 Apr 2024 22:06:06 +0000 Subject: [PATCH 11/15] Yeah, those were needed --- models/user.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/models/user.py b/models/user.py index fba41d1f1..2d35cd6b9 100644 --- a/models/user.py +++ b/models/user.py @@ -22,6 +22,8 @@ from . import bucketise, BaseModel from .permission import UserPermission, Permission from .volunteer.shift import ShiftEntry +from .web_push import WebPushTarget # noqa +from .notifications import UserNotificationPreference # noqa CHECKIN_CODE_LEN = 16 checkin_code_re = r"[0-9a-zA-Z_-]{%s}" % CHECKIN_CODE_LEN From 2f2ac3349dbee6fc2edc9e0dde544a3b01bc7ebb Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sun, 21 Apr 2024 20:51:48 +0000 Subject: [PATCH 12/15] Scheduled job to notify of upcoming favourites --- apps/notifications/jobs.py | 49 +++++++++++++++++-- ...34a04a10_add_unique_index_to_related_to.py | 26 ++++++++++ ...add_indexes_to_notification_preferences.py | 34 +++++++++++++ models/notifications.py | 6 +-- models/web_push.py | 10 ++-- 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 migrations/versions/91cd34a04a10_add_unique_index_to_related_to.py create mode 100644 migrations/versions/be0934972583_add_indexes_to_notification_preferences.py diff --git a/apps/notifications/jobs.py b/apps/notifications/jobs.py index 645fd2a34..ab505ad0a 100644 --- a/apps/notifications/jobs.py +++ b/apps/notifications/jobs.py @@ -1,9 +1,13 @@ +from sqlalchemy import and_ from main import db -from datetime import datetime +from datetime import datetime, timedelta from flask import current_app as app from models import scheduled_task +from models.cfp import FavouriteProposal, Proposal +from models.user import User from models.web_push import PushNotificationJob +from models.notifications import UserNotificationPreference from pywebpush import webpush, WebPushException @@ -36,10 +40,7 @@ def deliver_notification(job: PushNotificationJob): def send_queued_notifications(): jobs = PushNotificationJob.query.where( PushNotificationJob.state == "queued" - and ( - PushNotificationJob.not_before is None - or PushNotificationJob.not_before <= datetime.now() - ) + and (PushNotificationJob.not_before is None or PushNotificationJob.not_before <= datetime.now()) ).all() for job in jobs: @@ -47,3 +48,41 @@ def send_queued_notifications(): 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: + related_to = f"favourite,user:{user.id},proposal:{proposal.id},target:{target.id}" + if ( + PushNotificationJob.query.where( + PushNotificationJob.related_to == related_to + ).one_or_none() + is None + ): + job = PushNotificationJob( + target=target, + title=f"{proposal.title} is happening soon at {proposal.scheduled_venue.name}", + related_to=related_to, + not_before=proposal.scheduled_time - timedelta(minutes=15), + ) + print(f"Queued notification for {job.related_to}") + db.session.add(job) + + db.session.commit() diff --git a/migrations/versions/91cd34a04a10_add_unique_index_to_related_to.py b/migrations/versions/91cd34a04a10_add_unique_index_to_related_to.py new file mode 100644 index 000000000..25b679915 --- /dev/null +++ b/migrations/versions/91cd34a04a10_add_unique_index_to_related_to.py @@ -0,0 +1,26 @@ +"""add_unique_index_to_related_to + +Revision ID: 91cd34a04a10 +Revises: be0934972583 +Create Date: 2024-04-21 20:46:28.014878 + +""" + +# revision identifiers, used by Alembic. +revision = '91cd34a04a10' +down_revision = 'be0934972583' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(op.f('uq_push_notification_job_related_to'), 'push_notification_job', ['related_to']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('uq_push_notification_job_related_to'), 'push_notification_job', type_='unique') + # ### end Alembic commands ### diff --git a/migrations/versions/be0934972583_add_indexes_to_notification_preferences.py b/migrations/versions/be0934972583_add_indexes_to_notification_preferences.py new file mode 100644 index 000000000..237796fe7 --- /dev/null +++ b/migrations/versions/be0934972583_add_indexes_to_notification_preferences.py @@ -0,0 +1,34 @@ +"""add_indexes_to_notification_preferences + +Revision ID: be0934972583 +Revises: cd825d16072a +Create Date: 2024-04-21 09:53:36.317560 + +""" + +# revision identifiers, used by Alembic. +revision = 'be0934972583' +down_revision = 'cd825d16072a' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_user_notification_preference_announcements'), 'user_notification_preference', ['announcements'], unique=False) + op.create_index(op.f('ix_user_notification_preference_favourited_content'), 'user_notification_preference', ['favourited_content'], unique=False) + op.create_index(op.f('ix_user_notification_preference_volunteer_shifts'), 'user_notification_preference', ['volunteer_shifts'], unique=False) + op.drop_index('ix_web_push_target_user_id', table_name='web_push_target') + op.drop_index('ix_web_push_target_user_id_endpoint', table_name='web_push_target') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('ix_web_push_target_user_id_endpoint', 'web_push_target', ['user_id', 'endpoint'], unique=False) + op.create_index('ix_web_push_target_user_id', 'web_push_target', ['user_id'], unique=False) + op.drop_index(op.f('ix_user_notification_preference_volunteer_shifts'), table_name='user_notification_preference') + op.drop_index(op.f('ix_user_notification_preference_favourited_content'), table_name='user_notification_preference') + op.drop_index(op.f('ix_user_notification_preference_announcements'), table_name='user_notification_preference') + # ### end Alembic commands ### diff --git a/models/notifications.py b/models/notifications.py index 5ceea6991..9637491ce 100644 --- a/models/notifications.py +++ b/models/notifications.py @@ -8,9 +8,9 @@ class UserNotificationPreference(BaseModel): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - volunteer_shifts = db.Column(db.Boolean, default=False, nullable=False) - favourited_content = db.Column(db.Boolean, default=False, nullable=False) - announcements = db.Column(db.Boolean, default=False, nullable=False) + volunteer_shifts = db.Column(db.Boolean, default=False, nullable=False, index=True) + favourited_content = db.Column(db.Boolean, default=False, nullable=False, index=True) + announcements = db.Column(db.Boolean, default=False, nullable=False, index=True) user = db.relationship("User") diff --git a/models/web_push.py b/models/web_push.py index 78d407c91..c71648485 100644 --- a/models/web_push.py +++ b/models/web_push.py @@ -48,15 +48,11 @@ def __init__(self, user, endpoint, subscription_info, expires=None): class PushNotificationJob(BaseModel): __table_name__ = "web_push_notification_job" id: int = db.Column(db.Integer, primary_key=True) - target_id: int = db.Column( - db.Integer, db.ForeignKey("web_push_target.id"), nullable=False - ) + target_id: int = db.Column(db.Integer, db.ForeignKey("web_push_target.id"), nullable=False) created: datetime = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - state: Literal["queued", "delivered", "failed"] = db.Column( - db.String, default="queued", nullable=False - ) + state: Literal["queued", "delivered", "failed"] = db.Column(db.String, default="queued", nullable=False) not_before: datetime | None = db.Column(db.DateTime, nullable=True) - related_to: str | None = db.Column(db.String, nullable=True) + related_to: str | None = db.Column(db.String, nullable=True, unique=True) title: str = db.Column(db.String, nullable=False) body: str | None = db.Column(db.String, nullable=True) error: str | None = db.Column(db.String, nullable=True) From 067c29f927bc8662582898ed481c495719240428 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sun, 21 Apr 2024 20:56:25 +0000 Subject: [PATCH 13/15] Remove unused import --- apps/notifications/jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/notifications/jobs.py b/apps/notifications/jobs.py index ab505ad0a..86c15d8af 100644 --- a/apps/notifications/jobs.py +++ b/apps/notifications/jobs.py @@ -4,7 +4,7 @@ from flask import current_app as app from models import scheduled_task -from models.cfp import FavouriteProposal, Proposal +from models.cfp import Proposal from models.user import User from models.web_push import PushNotificationJob from models.notifications import UserNotificationPreference From a8758cda2673443a8252b95e5e845ab63051cedd Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Sun, 21 Apr 2024 21:04:00 +0000 Subject: [PATCH 14/15] Ignore pywebpush not having types --- .mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.mypy.ini b/.mypy.ini index 6e2e49b71..984f06fb1 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -94,3 +94,6 @@ ignore_missing_imports = True [mypy-stdnum.*] ignore_missing_imports = True + +[mypy-pywebpush.*] +ignore_missing_imports = True From 77d4d5576421a2c96459928e11881291d29c18d4 Mon Sep 17 00:00:00 2001 From: Jon Wood Date: Mon, 29 Apr 2024 21:03:24 +0000 Subject: [PATCH 15/15] Push notifications for upcoming volunteer shifts --- apps/base/__init__.py | 1 + apps/notifications/jobs.py | 88 ------------------------------ apps/notifications/tasks.py | 103 ++++++++++++++++++++++++++++++++++++ models/web_push.py | 25 +++++++++ 4 files changed, 129 insertions(+), 88 deletions(-) delete mode 100644 apps/notifications/jobs.py diff --git a/apps/base/__init__.py b/apps/base/__init__.py index 9e5602404..e3854cd7a 100644 --- a/apps/base/__init__.py +++ b/apps/base/__init__.py @@ -207,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 diff --git a/apps/notifications/jobs.py b/apps/notifications/jobs.py deleted file mode 100644 index 86c15d8af..000000000 --- a/apps/notifications/jobs.py +++ /dev/null @@ -1,88 +0,0 @@ -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.web_push import PushNotificationJob -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:contact@emfcamp.org", - }, - ) - - 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: - related_to = f"favourite,user:{user.id},proposal:{proposal.id},target:{target.id}" - if ( - PushNotificationJob.query.where( - PushNotificationJob.related_to == related_to - ).one_or_none() - is None - ): - job = PushNotificationJob( - target=target, - title=f"{proposal.title} is happening soon at {proposal.scheduled_venue.name}", - related_to=related_to, - not_before=proposal.scheduled_time - timedelta(minutes=15), - ) - print(f"Queued notification for {job.related_to}") - db.session.add(job) - - db.session.commit() diff --git a/apps/notifications/tasks.py b/apps/notifications/tasks.py index e69de29bb..23c38b2e3 100644 --- a/apps/notifications/tasks.py +++ b/apps/notifications/tasks.py @@ -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:contact@emfcamp.org", + }, + ) + + 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() diff --git a/models/web_push.py b/models/web_push.py index c71648485..96e64764c 100644 --- a/models/web_push.py +++ b/models/web_push.py @@ -22,6 +22,31 @@ def notify(target, message): ) +def enqueue_if_not_exists( + target: "WebPushTarget", + title: str, + body: str | None = None, + related_to: str | None = None, + not_before: datetime | None = None, +) -> "PushNotificationJob": + if related_to is not None: + existing_job = PushNotificationJob.query.where( + PushNotificationJob.related_to == related_to + ).one_or_none() + if existing_job is not None: + return existing_job + + job = PushNotificationJob( + target=target, + title=title, + body=body, + related_to=related_to, + not_before=not_before, + ) + db.session.add(job) + return job + + class WebPushTarget(BaseModel): __table_name__ = "web_push_target" id = db.Column(db.Integer, primary_key=True)