diff --git a/Dockerfile b/Dockerfile index dcdc2ce..4449547 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,4 +42,4 @@ USER myuser # Start gunicorn with the appropriate options # Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'printf' commands. -CMD ["gunicorn", "-b", "0.0.0.0:3000", "--log-level=debug", "--workers=1", "app:app", "2>&1"] +CMD ["sh", "-c", "gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app 2>&1"] diff --git a/app.py b/app.py index 2896b76..192c362 100644 --- a/app.py +++ b/app.py @@ -4,13 +4,14 @@ Environment variables: - ADMIN_PASSWORD: password for admin user for sensitive endpoints, defaults to 'admin' """ -from typing import Dict, Tuple, Union, Optional -from flask import Flask, request, jsonify, Response -from models import db, VAPIDKey, Subscription from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec +from flask import Flask, request, jsonify, Response +from models import db, VAPIDKey, Settings, Subscription from pywebpush import webpush, WebPushException +from sqlalchemy import and_ +from typing import Dict, Tuple import base64 import datetime @@ -141,8 +142,10 @@ class WebPushService(): - message (Dict): The actual message content to be sent as the push notification. - vapid_key (VAPIDKey): The VAPID key model instance containing the private key used for sending the notification. - Returns: - - request.Response https://requests.readthedocs.io/en/latest/api.html#requests.Response if the push notification was sent successfully + Returns Dict with the following keys + - success: True if the push notification was sent successfully, False otherwise + - message: a string message with the resulting text, usually nothing on success + - result: request.Response https://requests.readthedocs.io/en/latest/api.html#requests.Response if the push notification was sent successfully or False if there was an exception Notes: @@ -205,41 +208,108 @@ class WebPushService(): while True: - now = datetime.datetime.now().isoformat() + now = datetime.datetime.now() print(f"{now} - Starting to send subscriptions...", flush=True) # Creating a context for the application to enable database operations with self.app.app_context(): - # Retrieving all subscription data from the database - all_subscriptions = Subscription.query.all() - - # Retrieving the VAPID key from the database + # Retrieve the VAPID key from the database vapid_key = VAPIDKey.query.first() - # Constructing the push notification message + # Construct the push notification message # The title value is a key, triggering the device to apply logic and customize both title and message. # See https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/commit/1e6159869fc28ca6e6b5b3d186617d75705100b4/sw_scripts/additional-scripts.js#L65 UPDATE_TITLE = "DAILY_CHECK" message = {"title": UPDATE_TITLE, "message": f"Update for {now}"} - # Sending a push notification to each subscribed client - for subscription in all_subscriptions: - subscription_info = { - "endpoint": subscription.endpoint, - "keys": { - "p256dh": subscription.p256dh, - "auth": subscription.auth - } - } - result = WebPushService._send_push_notification(subscription_info, message, vapid_key) - print(f"Result from sub {subscription.id}: success={result['success']} text={result['message']}", flush=True) - print(f"{now} - Finished sending {len(all_subscriptions)} subscriptions.", flush=True) - - self.latest_subscription_run = now - - # Sleeping for 24 hours before sending the next set of notifications - time.sleep(24 * 60 * 60) + # Determine the beginning and end time to check for subscriptions + settings = Settings.query.first() + if settings.running_notify_end_time is None: + + # set all subscriptions without a time to the current time + # (these won't be picked-up in the current run, since thie current minute is the end minute) + Subscription.query.filter_by(notify_time=None).update({Subscription.notify_time: now.strftime('%H:%M')}) + + """ + Storing the HH:MM for the desired notification time isn't a bad idea. + + However, the logic that compares directly to it is a bit complicated. + It would be more straightforward to save the next notification time in each record + and then update that every time this process runs. It would require raw SQL with + some calculations and many DB updates each day, but the logic would be more clear. + """ + + # get the previous notify end time from the DB as a datetime + prev_notify_end_time = datetime.datetime.fromisoformat(settings.prev_notify_end_time) + # if it's before midnight this morning, catch us up to the beginning of today: + if prev_notify_end_time < now.replace(hour=0, minute=0, second=0, microsecond=0): + # make the start time the later of: prevNotifyEndTime or yesterday at this time + start_time = max( + prev_notify_end_time, + # if the current time is later in the day, use that + # because the next run will pick up everything from midnight until now today + now.replace(second=0, microsecond=0) - datetime.timedelta(days=1) + ) + # make the end time right at midnight at the beginning of today + end_time = now.replace(hour=0, minute=0, second=0, microsecond=0) + + start_minute = start_time.strftime('%H:%M') + # (we'd never catch anything if we used a non-zero start_minute and "00:00" as the end_minute) + end_minute = '23:60' # gotta catch "23:59", too + + else: + # the start time is OK + start_time = prev_notify_end_time + # the end time is now + end_time = now.replace(second=0, microsecond=0) + + start_minute = start_time.strftime('%H:%M') + end_minute = end_time.strftime('%H:%M') + + # This really should update & continue only if the running_notify_end_time is still None, + # just in case another thread started. + settings.running_notify_end_time = end_time.isoformat() + db.session.commit() + # this check was generated by Copilot; it's probably unnecessary + if settings.running_notify_end_time == end_time.isoformat(): + # Now get the 'HH:MM' value for start & end so we can compare to the notify_time field + + # get all the subscriptions that have a notify_time between start_minute inclusive and end_minute exclusive + all_subscriptions = Subscription.query.filter( + and_(Subscription.notify_time >= start_minute, Subscription.notify_time < end_minute) + ) + + # Send a push notification to each subscribed client + num_subscriptions = all_subscriptions.count() + for subscription in all_subscriptions: + subscription_info = { + "endpoint": subscription.endpoint, + "keys": { + "p256dh": subscription.p256dh, + "auth": subscription.auth + } + } + result = WebPushService._send_push_notification(subscription_info, message, vapid_key) + print( + f"Result from sub {subscription.id}: success={result['success']} text={result['message']}", + flush=True + ) + + settings.prev_notify_end_time = end_time.isoformat() + settings.running_notify_end_time = None + db.session.commit() + print(f"{now} - Finished sending {num_subscriptions} subscriptions.", flush=True) + else: + print(f"{now} - Failed to update running_notify_end_time", flush=True) + else: + print(f"{now} - Stopped because we're already running a notification check.", flush=True) + + + self.latest_subscription_run = now.isoformat() + + # Sleep before repeating + time.sleep(5 * 60) # This is an endpoint, routed in __init__ diff --git a/data/webpush.db.empty b/data/webpush.db.empty index ede51c9..0560268 100644 Binary files a/data/webpush.db.empty and b/data/webpush.db.empty differ diff --git a/models.py b/models.py index 21e0d4d..ef9a08a 100644 --- a/models.py +++ b/models.py @@ -8,11 +8,15 @@ class VAPIDKey(db.Model): private_key = db.Column(db.String(255), nullable=False) subscriptions = db.relationship('Subscription', backref='vapid_key', lazy=True) +class Settings(db.Model): + id = db.Column(db.Integer, primary_key=True) + prev_notify_end_time = db.Column(db.String(29), nullable=False) + running_notify_end_time = db.Column(db.String(29), nullable=True) + class Subscription(db.Model): id = db.Column(db.Integer, primary_key=True) + auth = db.Column(db.String(255), nullable=False) endpoint = db.Column(db.String(500), nullable=False) + notify_time = db.Column(db.String(5), nullable=True) # HH:MM p256dh = db.Column(db.String(255), nullable=False) - auth = db.Column(db.String(255), nullable=False) vapid_key_id = db.Column(db.Integer, db.ForeignKey('vapid_key.id'), nullable=False) - -