Implement Web Push Notifications system

- Add push_notifications.py module for managing subscriptions and sending notifications
- Create Flask API endpoints for subscription management (/api/push/*)
- Integrate push sending with existing notification system (_create_notification)
- Create frontend JavaScript library (push-notifications.js) for client-side subscription
- Update base.html template with manifest link and Service Worker registration
- Add manifest.json and service-worker.js for PWA support
- Add pywebpush dependency to requirements.txt
- Create generate-vapid-keys.sh script for VAPID key generation
- Add comprehensive documentation (WEB_PUSH_NOTIFICATIONS.md)

Features:
- Subscribe/unsubscribe users from push notifications
- Store subscriptions in MongoDB with automatic indexing
- Send push to individual users or all admins
- Notification deduplication and versioning
- Service Worker handles offline caching and push events
- Full PWA support for mobile app installation
- VAPID authentication for secure push communication
This commit is contained in:
2026-04-19 13:59:00 +02:00
parent 3469264445
commit f98ad5d0ee
9 changed files with 1961 additions and 1 deletions
+202
View File
@@ -32,6 +32,7 @@ import user as us
import items as it
import ausleihung as au
import audit_log as al
import push_notifications as pn
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId
@@ -745,8 +746,30 @@ def _create_notification(db, *, audience, notif_type, title, message, target_use
if audience == 'user' and target_user:
_bump_notification_version(f'user:{target_user}')
# Send push notification to user
try:
pn.send_push_notification(
target_user,
title,
message,
url=reference.get('url', '/') if reference else '/',
reference=reference,
tag=f'notification-{notif_type}'
)
except Exception as e:
app.logger.warning(f'Failed to send push notification to {target_user}: {e}')
elif audience == 'admin':
_bump_notification_version('admin')
# Send push notification to all admins
try:
pn.send_push_to_all_admins(
title,
message,
url=reference.get('url', '/') if reference else '/',
reference=reference
)
except Exception as e:
app.logger.warning(f'Failed to send push to admins: {e}')
return True
@@ -10740,3 +10763,182 @@ def get_optimal_image_quality(img, target_size_kb=80):
return quality
return best_quality
# ============================================================================
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
Subscribe a user to push notifications
Expects JSON payload:
{
'subscription': {
'endpoint': '...',
'keys': {'p256dh': '...', 'auth': '...'}
}
}
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
data = request.get_json() or {}
subscription = data.get('subscription')
if not subscription or not subscription.get('endpoint'):
return jsonify({'success': False, 'error': 'Invalid subscription'}), 400
username = session['username']
success = pn.save_push_subscription(username, subscription)
if success:
app.logger.info(f'Push subscription saved for {username}')
return jsonify({
'success': True,
'message': 'Successfully subscribed to push notifications'
})
else:
return jsonify({'success': False, 'error': 'Failed to save subscription'}), 500
except Exception as e:
app.logger.error(f'Error subscribing to push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/push/unsubscribe', methods=['POST'])
def unsubscribe_from_push():
"""
Unsubscribe a user from push notifications
Expects JSON payload:
{
'endpoint': '...'
}
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'success': False, 'error': 'Missing endpoint'}), 400
username = session['username']
success = pn.remove_push_subscription(username, endpoint)
if success:
app.logger.info(f'Push subscription removed for {username}')
return jsonify({
'success': True,
'message': 'Successfully unsubscribed from push notifications'
})
else:
return jsonify({'success': False, 'error': 'Subscription not found'}), 404
except Exception as e:
app.logger.error(f'Error unsubscribing from push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/push/subscriptions', methods=['GET'])
def get_push_subscriptions():
"""
Get all push subscriptions for the current user
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
username = session['username']
subscriptions = pn.get_user_subscriptions(username)
# Convert ObjectIds to strings for JSON serialization
subs_data = []
for sub in subscriptions:
subs_data.append({
'id': str(sub['_id']),
'endpoint': sub.get('Endpoint', ''),
'created_at': sub.get('CreatedAt', '').isoformat() if sub.get('CreatedAt') else '',
'last_used': sub.get('LastUsed', '').isoformat() if sub.get('LastUsed') else '',
'user_agent': sub.get('UserAgent', ''),
})
return jsonify({
'success': True,
'subscriptions': subs_data,
'count': len(subs_data)
})
except Exception as e:
app.logger.error(f'Error getting push subscriptions: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Get the VAPID public key for push notifications
Used by the service worker to communicate with push service
"""
try:
vapid_key = pn.VAPID_PUBLIC_KEY
if not vapid_key:
app.logger.warning('VAPID_PUBLIC_KEY not configured')
return jsonify({
'success': False,
'error': 'Push notifications not configured on server'
}), 501
return jsonify({
'success': True,
'vapid_key': vapid_key
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/push/test', methods=['POST'])
def test_push_notification():
"""
Send a test push notification (admin only)
"""
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
if not us.is_admin(session['username']):
return jsonify({'success': False, 'error': 'Admin access required'}), 403
try:
data = request.get_json() or {}
target_user = data.get('target_user', session['username'])
sent = pn.send_push_notification(
target_user,
'Test Benachrichtigung',
'Dies ist eine Test-Benachrichtigung von Inventarsystem',
url='/',
tag='test-notification'
)
if sent > 0:
return jsonify({
'success': True,
'message': f'Test push sent to {sent} subscription(s)'
})
else:
return jsonify({
'success': False,
'error': 'No active subscriptions for user'
}), 404
except Exception as e:
app.logger.error(f'Error sending test push: {e}')
return jsonify({'success': False, 'error': str(e)}), 500