Merge pull request 'debug daily notification subscriptions' (#5) from debug-subsc into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
18
CHANGELOG.md
Normal file
18
CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [0.2.0] - 2024-03-31
|
||||
### Added
|
||||
- Different times for users to receive notifications
|
||||
- Ping endpoint
|
||||
### Changed
|
||||
- Notification loop runs every 5 minutes.
|
||||
|
||||
|
||||
## [0.1.0]
|
||||
- First release with subscriptions and daily notifications.
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,11 +1,10 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.8-alpine3.18 as builder
|
||||
FROM python:alpine3.19 as builder
|
||||
|
||||
RUN apk update && apk upgrade
|
||||
RUN apk add --no-cache --virtual .build-deps build-base git
|
||||
RUN apk add --upgrade --no-cache bash sqlite libffi-dev tzdata
|
||||
|
||||
ENV TZ America/New_York
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Set the working directory in the container to /app
|
||||
@@ -25,7 +24,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN apk del .build-deps
|
||||
|
||||
# ---- Production Stage ----
|
||||
FROM python:3.8-alpine3.18 as production
|
||||
FROM python:alpine3.19 as production
|
||||
|
||||
ARG PUSH_SERVER_VERSION
|
||||
ENV PUSH_SERVER_VERSION=${PUSH_SERVER_VERSION}
|
||||
|
||||
# Create a user to run our application
|
||||
RUN adduser -D myuser -u 1000
|
||||
@@ -41,4 +43,5 @@ RUN chown -R myuser:myuser /app
|
||||
USER myuser
|
||||
|
||||
# Start gunicorn with the appropriate options
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:3000", "--log-level=debug", "--workers=1", "app:app"]
|
||||
# Without "2>&1" the gunicorn internal logging shows in 'docker logs' but doesn't go to stdout like our 'print' commands.
|
||||
CMD ["sh", "-c", "gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app 2>&1"]
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Docker Build & Deploy
|
||||
|
||||
- Update CHANGELOG.md
|
||||
|
||||
- Commit & tag with release version
|
||||
|
||||
```
|
||||
export PUSH_SERVER_VERSION=0.1
|
||||
|
||||
@@ -240,6 +244,7 @@ Run the app:
|
||||
```commandline
|
||||
sh <(curl https://pkgx.sh) +python.org +virtualenv.pypa.io sh
|
||||
|
||||
# first time
|
||||
python -m venv .
|
||||
|
||||
source bin/activate
|
||||
@@ -249,6 +254,8 @@ pip install -r requirements.txt
|
||||
|
||||
cp data/webpush.db.empty data/webpush.db
|
||||
|
||||
# For DB access, you'll have to uncomment the local path for `db_uri`.
|
||||
|
||||
# 3 workers would trigger 3 daily subscription runs
|
||||
gunicorn -b 0.0.0.0:3000 --log-level=debug --workers=1 app:app
|
||||
|
||||
|
||||
157
app.py
157
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
|
||||
@@ -21,9 +22,14 @@ import time
|
||||
|
||||
CONTACT_EMAIL = "mailto:info@timesafari.app"
|
||||
|
||||
PUSH_SERVER_VERSION = os.getenv('PUSH_SERVER_VERSION')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
class WebPushService():
|
||||
|
||||
latest_subscription_run = None
|
||||
|
||||
"""
|
||||
This class provides services for sending web push notifications.
|
||||
"""
|
||||
@@ -44,10 +50,12 @@ class WebPushService():
|
||||
# Setting the application instance
|
||||
self.app = app
|
||||
self.app.add_url_rule('/web-push/regenerate-vapid', view_func=self.regenerate_vapid, methods=['POST'])
|
||||
self.app.add_url_rule('/web-push/ping', view_func=self.ping, methods=['GET'])
|
||||
|
||||
# Setting the database URI for the application
|
||||
db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:////app/instance/data/webpush.db')
|
||||
# This relative path works in docker-compose
|
||||
# This relative path works on a local run if you link to this dir from "var/app-instance"
|
||||
#db_uri = os.getenv('SQLALCHEMY_DATABASE_URI', 'sqlite:///data/webpush.db')
|
||||
self.app.config['SQLALCHEMY_DATABASE_URI'] = db_uri
|
||||
|
||||
@@ -126,7 +134,7 @@ class WebPushService():
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> bool:
|
||||
def _send_push_notification(subscription_info: Dict, message: Dict, vapid_key: VAPIDKey) -> Dict[str, any]:
|
||||
"""
|
||||
Sends a push notification using the provided subscription information, message, and VAPID key.
|
||||
|
||||
@@ -135,8 +143,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:
|
||||
@@ -199,25 +209,80 @@ 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
|
||||
# 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,
|
||||
@@ -227,15 +292,39 @@ class WebPushService():
|
||||
}
|
||||
}
|
||||
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)
|
||||
print(
|
||||
f"Result from sub {subscription.id}: success={result['success']} text={result['message']}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
# Sleeping for 24 hours before sending the next set of notifications
|
||||
time.sleep(24 * 60 * 60)
|
||||
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__
|
||||
def regenerate_vapid(self) -> Tuple[str, int]:
|
||||
def ping(self) -> str:
|
||||
"""
|
||||
Endpoint to show liveness info
|
||||
|
||||
Returns:
|
||||
- Response: Text with some subscription-run info
|
||||
"""
|
||||
|
||||
return f"pong ... version {PUSH_SERVER_VERSION} ... with latest subscription run at {self.latest_subscription_run}"
|
||||
|
||||
# This is an endpoint, routed in __init__
|
||||
def regenerate_vapid(self) -> Tuple[Response, int, dict[str, str]] | Tuple[Response, int]:
|
||||
"""
|
||||
Endpoint to regenerate VAPID keys.
|
||||
|
||||
@@ -248,7 +337,7 @@ class WebPushService():
|
||||
Header: Authentication: Basic ...
|
||||
|
||||
Returns:
|
||||
- tuple with "success" as True or False, and "message" message string
|
||||
- Tuple with "success" as True or False, and "message" message string
|
||||
|
||||
Notes:
|
||||
- If the operation is successful, a JSON response with a success message is returned with a 200 status code.
|
||||
@@ -312,7 +401,7 @@ class WebPushService():
|
||||
|
||||
@staticmethod
|
||||
@app.route('/web-push/subscribe', methods=['POST'])
|
||||
def subscribe() -> Tuple[str, int]:
|
||||
def subscribe() -> Tuple[Response, int]:
|
||||
"""
|
||||
Endpoint to handle new web push subscription requests.
|
||||
|
||||
@@ -344,10 +433,21 @@ class WebPushService():
|
||||
if not vapid_key:
|
||||
return jsonify(success=False, message="No VAPID keys available"), 500
|
||||
|
||||
# Constructing the notify_time string
|
||||
notify_time = "13:13" # random time that is in most people's waking hours (server time, typically UTC)
|
||||
if ('notifyTime' in content) and ('utcHour' in content['notifyTime']):
|
||||
notify_hour = content['notifyTime']['utcHour']
|
||||
if 'minute' in content['notifyTime']:
|
||||
notify_minute = content['notifyTime']['minute']
|
||||
else:
|
||||
notify_minute = 0
|
||||
notify_time = '{:02d}'.format(notify_hour) + ":" + '{:02d}'.format(notify_minute)
|
||||
|
||||
# Creating a new Subscription instance with the provided data
|
||||
subscription = Subscription(endpoint=content['endpoint'],
|
||||
subscription = Subscription(auth=content['keys']['auth'],
|
||||
endpoint=content['endpoint'],
|
||||
notify_time=notify_time,
|
||||
p256dh=content['keys']['p256dh'],
|
||||
auth=content['keys']['auth'],
|
||||
vapid_key_id=vapid_key.id)
|
||||
|
||||
# Saving the subscription data to the database
|
||||
@@ -355,7 +455,8 @@ class WebPushService():
|
||||
db.session.commit()
|
||||
|
||||
# Introducing a delay (ensure that gateway endpoint is available)
|
||||
time.sleep(10)
|
||||
# ... which I'm now commenting out because there's no pending request so it doesn't make sense... we'll see if things still work
|
||||
#time.sleep(10)
|
||||
|
||||
# Constructing the subscription information for the push notification
|
||||
subscription_info = {
|
||||
@@ -373,12 +474,12 @@ class WebPushService():
|
||||
result = WebPushService._send_push_notification(subscription_info, message, vapid_key)
|
||||
|
||||
# Returning the operation status
|
||||
return jsonify(success=result["success"], message=result["message"])
|
||||
return jsonify(success=result["success"], message=result["message"]), 200
|
||||
|
||||
|
||||
@staticmethod
|
||||
@app.route('/web-push/unsubscribe', methods=['POST'])
|
||||
def unsubscribe() -> Tuple[str, int]:
|
||||
def unsubscribe() -> Tuple[Response, int]:
|
||||
"""
|
||||
Endpoint to handle web push unsubscription requests.
|
||||
|
||||
@@ -409,7 +510,7 @@ class WebPushService():
|
||||
if subscription:
|
||||
db.session.delete(subscription)
|
||||
db.session.commit()
|
||||
return jsonify(success=True, message="Subscription deleted successfully")
|
||||
return jsonify(success=True, message="Subscription deleted successfully"), 200
|
||||
|
||||
# If the subscription is not found, return an error message
|
||||
else:
|
||||
@@ -418,7 +519,7 @@ class WebPushService():
|
||||
|
||||
@staticmethod
|
||||
@app.route('/web-push/send-test', methods=['POST'])
|
||||
def send_test() -> Tuple[str, int]:
|
||||
def send_test() -> Tuple[Response, int]:
|
||||
"""
|
||||
Endpoint to send a test push notification to a specific client.
|
||||
|
||||
@@ -473,7 +574,7 @@ class WebPushService():
|
||||
)
|
||||
|
||||
print(f"Test sent: {result['success']}")
|
||||
return jsonify(success=result["success"], message=result["message"])
|
||||
return jsonify(success=result["success"], message=result["message"]), 200
|
||||
else:
|
||||
print(f"Test failed due to missing subscription. Request: {json.dumps(content)}")
|
||||
return jsonify({"success": False, "message": "Subscription not found"}), 404
|
||||
|
||||
Binary file not shown.
12
models.py
12
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)
|
||||
endpoint = db.Column(db.String(500), nullable=False)
|
||||
p256dh = db.Column(db.String(255), nullable=False)
|
||||
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)
|
||||
vapid_key_id = db.Column(db.Integer, db.ForeignKey('vapid_key.id'), nullable=False)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user