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
+470
View File
@@ -0,0 +1,470 @@
/**
* Push Notification Management
* Handles subscription, unsubscription, and UI updates for web push notifications
*/
class PushNotificationManager {
constructor() {
this.serviceWorkerRegistration = null;
this.vapidKey = null;
this.isSupported = this.checkSupport();
}
/**
* Check if push notifications are supported
*/
checkSupport() {
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
/**
* Initialize push notification system
* Must be called after page load
*/
async init() {
if (!this.isSupported) {
console.log('Push notifications not supported in this browser');
return false;
}
try {
// Get service worker registration
const registrations = await navigator.serviceWorker.getRegistrations();
this.serviceWorkerRegistration = registrations.find(
reg => reg.scope === window.location.origin + '/'
);
if (!this.serviceWorkerRegistration) {
console.warn('Service Worker not found, retrying...');
// Try registering if not already done
try {
this.serviceWorkerRegistration = await navigator.serviceWorker.register('/static/service-worker.js');
} catch (err) {
console.error('Failed to register Service Worker:', err);
return false;
}
}
// Fetch VAPID public key from server
const keyResponse = await fetch('/api/push/vapid-key');
if (keyResponse.ok) {
const keyData = await keyResponse.json();
this.vapidKey = keyData.vapid_key;
} else {
console.warn('Failed to fetch VAPID key');
return false;
}
return true;
} catch (error) {
console.error('Failed to initialize push notifications:', error);
return false;
}
}
/**
* Request notification permission from user
*/
async requestPermission() {
if (!this.isSupported) {
console.warn('Push notifications not supported');
return false;
}
if (Notification.permission === 'granted') {
console.log('Push notifications already permitted');
return true;
}
if (Notification.permission === 'denied') {
console.warn('Push notifications have been denied by user');
return false;
}
try {
const permission = await Notification.requestPermission();
return permission === 'granted';
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}
/**
* Subscribe to push notifications
*/
async subscribe() {
if (!this.isSupported) {
console.error('Push notifications not supported');
return false;
}
if (!this.serviceWorkerRegistration) {
console.error('Service Worker not initialized');
return false;
}
if (!this.vapidKey) {
console.error('VAPID key not available');
return false;
}
try {
// Check if already subscribed
const existingSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
if (existingSubscription) {
console.log('Already subscribed to push notifications');
return await this.saveSubscriptionToServer(existingSubscription);
}
// Request permission if needed
const hasPermission = await this.requestPermission();
if (!hasPermission) {
console.warn('User denied notification permission');
return false;
}
// Subscribe to push service
const subscription = await this.serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidKey)
});
// Save subscription to server
return await this.saveSubscriptionToServer(subscription);
} catch (error) {
console.error('Failed to subscribe to push notifications:', error);
return false;
}
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe() {
if (!this.serviceWorkerRegistration) {
console.error('Service Worker not initialized');
return false;
}
try {
const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
if (!subscription) {
console.warn('No active push subscription');
return false;
}
// Remove subscription on server
const success = await this.removeSubscriptionFromServer(subscription);
// Unsubscribe from push service
if (success) {
await subscription.unsubscribe();
return true;
}
return false;
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error);
return false;
}
}
/**
* Check if user is subscribed to push notifications
*/
async isSubscribed() {
if (!this.serviceWorkerRegistration) {
return false;
}
try {
const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
return subscription !== null;
} catch (error) {
console.error('Failed to check subscription status:', error);
return false;
}
}
/**
* Save subscription to server
*/
async saveSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription.toJSON()
})
});
if (response.ok) {
const data = await response.json();
console.log('Subscription saved to server:', data.message);
return true;
} else {
const error = await response.json();
console.error('Failed to save subscription:', error.error);
return false;
}
} catch (error) {
console.error('Error communicating with server:', error);
return false;
}
}
/**
* Remove subscription from server
*/
async removeSubscriptionFromServer(subscription) {
try {
const response = await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: subscription.endpoint
})
});
if (response.ok) {
console.log('Subscription removed from server');
return true;
} else {
const error = await response.json();
console.error('Failed to remove subscription:', error.error);
return false;
}
} catch (error) {
console.error('Error communicating with server:', error);
return false;
}
}
/**
* Get all subscriptions for current user
*/
async getSubscriptions() {
try {
const response = await fetch('/api/push/subscriptions');
if (response.ok) {
const data = await response.json();
return data.subscriptions || [];
}
return [];
} catch (error) {
console.error('Error fetching subscriptions:', error);
return [];
}
}
/**
* Send test notification (admin only)
*/
async sendTestNotification(targetUser = null) {
try {
const response = await fetch('/api/push/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target_user: targetUser
})
});
if (response.ok) {
const data = await response.json();
console.log('Test notification sent:', data.message);
return true;
} else {
const error = await response.json();
console.error('Failed to send test notification:', error.error);
return false;
}
} catch (error) {
console.error('Error sending test notification:', error);
return false;
}
}
/**
* Convert VAPID key from base64 string to Uint8Array
* Required for subscribing to push service
*/
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
}
// Create global instance
const pushNotificationManager = new PushNotificationManager();
/**
* Show notification subscription UI (typically in settings)
*/
function showPushNotificationSettings() {
const container = document.getElementById('push-notification-settings');
if (!container) return;
if (!pushNotificationManager.isSupported) {
container.innerHTML = '<p class="text-muted">Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.</p>';
return;
}
const html = `
<div class="push-notification-settings">
<h5>Push-Benachrichtigungen</h5>
<p>Erhalten Sie Benachrichtigungen für wichtige Ereignisse direkt auf Ihrem Gerät.</p>
<div id="push-status" style="margin: 15px 0;"></div>
<button id="toggle-push-btn" class="btn btn-primary" style="margin-bottom: 10px;">
Benachrichtigungen aktivieren
</button>
<div id="subscriptions-list" style="margin-top: 15px;"></div>
</div>
`;
container.innerHTML = html;
// Set up button handler
const toggleBtn = document.getElementById('toggle-push-btn');
pushNotificationManager.init().then(() => {
updatePushStatus();
});
toggleBtn.addEventListener('click', togglePushNotifications);
}
/**
* Update push notification status display
*/
async function updatePushStatus() {
const statusDiv = document.getElementById('push-status');
const toggleBtn = document.getElementById('toggle-push-btn');
if (!statusDiv || !toggleBtn) return;
const isSubscribed = await pushNotificationManager.isSubscribed();
const permission = Notification.permission;
let statusHtml = '<div class="alert alert-info">';
if (isSubscribed) {
statusHtml += '<strong>✓ Aktiv:</strong> Sie erhalten Push-Benachrichtigungen.';
toggleBtn.textContent = 'Benachrichtigungen deaktivieren';
toggleBtn.classList.remove('btn-primary');
toggleBtn.classList.add('btn-danger');
} else if (permission === 'denied') {
statusHtml += '<strong>✗ Blockiert:</strong> Benachrichtigungen wurden abgelehnt. Bitte überprüfen Sie Ihre Browser-Einstellungen.';
toggleBtn.disabled = true;
toggleBtn.textContent = 'Benachrichtigungen deaktiviert';
} else {
statusHtml += '<strong>○ Inaktiv:</strong> Sie erhalten derzeit keine Push-Benachrichtigungen.';
toggleBtn.textContent = 'Benachrichtigungen aktivieren';
toggleBtn.classList.add('btn-primary');
toggleBtn.classList.remove('btn-danger');
}
statusHtml += '</div>';
statusDiv.innerHTML = statusHtml;
// Show subscriptions list
const subscriptionsList = document.getElementById('subscriptions-list');
if (subscriptionsList && isSubscribed) {
const subs = await pushNotificationManager.getSubscriptions();
if (subs.length > 0) {
let subsHtml = '<h6>Aktive Abos:</h6><ul class="list-group">';
subs.forEach(sub => {
const endpoint = new URL(sub.endpoint);
subsHtml += `<li class="list-group-item"><small>${endpoint.hostname}</small><br><small class="text-muted">${sub.created_at}</small></li>`;
});
subsHtml += '</ul>';
subscriptionsList.innerHTML = subsHtml;
}
}
}
/**
* Toggle push notifications on/off
*/
async function togglePushNotifications() {
const isSubscribed = await pushNotificationManager.isSubscribed();
if (isSubscribed) {
// Unsubscribe
const success = await pushNotificationManager.unsubscribe();
if (success) {
showAlert('Benachrichtigungen deaktiviert', 'success');
} else {
showAlert('Fehler beim Deaktivieren der Benachrichtigungen', 'danger');
}
} else {
// Subscribe
const success = await pushNotificationManager.subscribe();
if (success) {
showAlert('Benachrichtigungen aktiviert!', 'success');
} else {
showAlert('Fehler beim Aktivieren der Benachrichtigungen', 'danger');
}
}
// Update status display
updatePushStatus();
}
/**
* Show an alert message
*/
function showAlert(message, type = 'info') {
// Try to show toast or alert
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Find a good place to show the alert
const container = document.querySelector('.container-fluid') || document.body;
const firstElement = container.firstChild;
if (firstElement) {
container.insertBefore(alertDiv, firstElement);
} else {
container.appendChild(alertDiv);
}
// Auto-dismiss after 5 seconds
setTimeout(() => {
alertDiv.remove();
}, 5000);
}
// Auto-initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
await pushNotificationManager.init();
});
+134
View File
@@ -0,0 +1,134 @@
{
"name": "Inventarsystem",
"short_name": "Inventar",
"description": "Verwaltungssystem für Schulinventare mit Ausleihmanagement",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#007bff",
"dir": "ltr",
"lang": "de-DE",
"prefer_related_applications": false,
"related_applications": [],
"icons": [
{
"src": "/static/img/logo-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/img/logo-192x192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/img/logo-512x512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/static/img/screenshot-540x720.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/static/img/screenshot-1280x720.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["education", "productivity"],
"shortcuts": [
{
"name": "Meine Ausleihungen",
"short_name": "Ausleihungen",
"description": "Zeige meine aktuellen Ausleihungen",
"url": "/my_borrowed_items",
"icons": [
{
"src": "/static/img/shortcut-96x96.png",
"sizes": "96x96"
}
]
},
{
"name": "Kalender",
"short_name": "Kalender",
"description": "Kalender für Ausleihplanungen",
"url": "/terminplan",
"icons": [
{
"src": "/static/img/shortcut-96x96.png",
"sizes": "96x96"
}
]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*", "video/*"]
}
]
}
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Service Worker for Web Push Notifications
* Handles incoming push events and displays notifications
*/
const CACHE_NAME = 'inventarsystem-v1';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
'/static/js/scripts.js',
'/offline.html'
];
// Install event - cache assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE).catch((err) => {
console.warn('[Service Worker] Cache add error:', err);
// Don't fail the installation
return Promise.resolve();
});
})
);
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// Push event - handle incoming push notifications
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push notification received');
let notificationData = {
title: 'Inventarsystem',
body: 'Neue Benachrichtigung',
icon: '/static/img/logo-192x192.png',
badge: '/static/img/badge-72x72.png',
tag: 'inventarsystem-notification',
requireInteraction: false,
};
// Parse the push event data if available
if (event.data) {
try {
const data = event.data.json();
notificationData = {
title: data.title || notificationData.title,
body: data.message || data.body || notificationData.body,
icon: data.icon || notificationData.icon,
badge: data.badge || notificationData.badge,
tag: data.tag || notificationData.tag,
requireInteraction: data.requireInteraction || false,
data: {
url: data.url || '/',
reference: data.reference || null,
type: data.type || 'info',
},
};
console.log('[Service Worker] Push data parsed:', notificationData);
} catch (err) {
console.error('[Service Worker] Failed to parse push data:', err);
notificationData.body = event.data.text();
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, {
body: notificationData.body,
icon: notificationData.icon,
badge: notificationData.badge,
tag: notificationData.tag,
requireInteraction: notificationData.requireInteraction,
data: notificationData.data,
actions: [
{
action: 'open',
title: 'Öffnen',
icon: '/static/img/open-icon.png'
},
{
action: 'close',
title: 'Schließen',
icon: '/static/img/close-icon.png'
}
],
})
);
});
// Notification click event - handle user clicks on notifications
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked:', event.action);
event.notification.close();
const urlToOpen = event.notification.data.url || '/';
event.waitUntil(
clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((windowClients) => {
// Check if the app is already open
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i];
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// If not open, open a new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// Notification close event - track when users dismiss notifications
self.addEventListener('notificationclose', (event) => {
console.log('[Service Worker] Notification closed');
// Could send analytics here
});
// Background fetch event (optional, for large data syncs)
self.addEventListener('sync', (event) => {
console.log('[Service Worker] Background sync triggered:', event.tag);
if (event.tag === 'sync-notifications') {
event.waitUntil(
fetch('/api/notifications/sync').then((response) => {
console.log('[Service Worker] Notifications synced');
})
);
}
});
// Fetch event - serve cached content when offline
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip API calls - always use network
if (event.request.url.includes('/api/')) {
return;
}
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
// Cache successful responses
if (response && response.status === 200) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
}).catch(() => {
// Return offline page
return caches.match('/offline.html');
});
})
);
});