Compare commits

...

10 Commits

Author SHA1 Message Date
Aiirondev_dev f98ad5d0ee 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
2026-04-19 13:59:00 +02:00
Aiirondev_dev 3469264445 Add debugging for planned booking activation issues 2026-04-19 13:40:27 +02:00
Aiirondev_dev a2a0f0dc53 fix: Improve pin_compose_app_image script with clearer state tracking and proper build block handling 2026-04-19 13:24:17 +02:00
Aiirondev_dev c551352f25 Fix docker-compose YAML structure: normalize depends_on formatting and fix pin_compose_app_image script 2026-04-19 13:19:22 +02:00
Aiirondev_dev 3a845ce07f feat: Update nginx service dependencies to ensure they start only after app and redis services 2026-04-19 13:13:51 +02:00
Aiirondev_dev 1bfe998906 feat: Update nginx service dependency to ensure it starts only after the app service 2026-04-19 13:08:41 +02:00
Aiirondev_dev 3fdbadd454 feat: Refactor image handling in Docker Compose file processing for improved indentation management 2026-04-19 12:59:23 +02:00
Aiirondev_dev 110327b73e feat: Enhance scheduler initialization to clean up stale lock files for multi-worker deployments 2026-04-18 14:02:28 +02:00
Aiirondev_dev d8cd1906b3 feat: Update item status handling during appointment activation and cancellation 2026-04-18 13:18:17 +02:00
Aiirondev_dev b8a7d6c797 feat: Enhance appointment scheduling to set initial status and notify users on activation 2026-04-18 12:57:14 +02:00
19 changed files with 2387 additions and 41 deletions
View File
+177
View File
@@ -0,0 +1,177 @@
# Fehlersuche: Geplante Ausleihen werden nicht automatisch aktiviert
## 🔍 Mögliche Ursachen
### 1. **Termin wurde für falsches Datum erstellt**
Die meisten Probleme entstehen, weil der Termin für ein **falsches Datum** (z.B. morgen statt heute) erstellt wurde.
### 2. **Scheduler läuft nicht**
Der automatische Scheduler kann deaktiviert sein oder nicht starten.
### 3. **Zeitzonen-Problem**
Die Startzeit kann in einer anderen Zeitzone gespeichert sein als erwartet.
---
## ✅ SOFORT-FIX: Manuell aktivieren
Falls du eine geplante Ausleihe sofort aktivieren möchtest, nutze diesen Befehl auf dem Server:
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.ausleihungen.updateOne(
{_id: ObjectId('BOOKING_ID_HIER')},
{\$set: {Status: 'active', LastUpdated: new Date()}}
)" Inventarsystem
```
Ersetze `BOOKING_ID_HIER` mit der ID deiner Ausleihe.
---
## 🔧 DEBUG: Alle geplanten Ausleihen anschauen
Führe auf deinem Server aus:
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.ausleihungen.find({Status: 'planned'}).pretty()
" Inventarsystem
```
Das zeigt dir:
- **Start**: Das gespeicherte Startdatum/Uhrzeit
- **Periode**: Die Schulstunde (falls verwendet)
- **Erstellungs-Zeit vs. aktuelle Zeit**: Vergleich
---
## 🎯 PROBLEM 1: Falsches Datum beim Erstellen
**Symptom**: Start-Zeit ist in der Zukunft (z.B. morgen statt heute)
**Lösung**:
1. In der Kalender-Ansicht das **richtige Datum** auswählen
2. Die **aktuelle Uhrzeit** für den Beginn nutzen (nicht eine zukünftige Zeit)
3. Für Schulstunden: Stelle sicher die **heutige Schulstunde** zu wählen
---
## 🎯 PROBLEM 2: Scheduler läuft nicht
**Symptom**: Keine Logs über Termin-Aktivierungen
**Überprüfung**:
```bash
# 1. Container-Logs anschauen
docker logs inventarsystem-app | grep -i "scheduler\|appointment\|status update"
# 2. Scheduler ist aktiviert?
docker exec inventarsystem-app python3 -c "
import sys
sys.path.insert(0, '/app/Web')
import settings as cfg
print(f'Scheduler aktiviert: {cfg.SCHEDULER_ENABLED}')
print(f'Scheduler Intervall: {cfg.SCHEDULER_INTERVAL_MIN} Minuten')
"
```
**Lösung wenn deaktiviert**:
- In `config.json` ändern:
```json
"scheduler": {
"enabled": true,
"interval_minutes": 1
}
```
- Container neu starten: `docker-compose restart app`
---
## 🎯 PROBLEM 3: Startzeit als nur-Zeit (kein Datum)
**Symptom**: Start-Zeit zeigt nur "13:30" ohne Datum
**Überprüfung**: In der Datenbank nach `{Start: {$type: "string"}}` suchen statt datetime
**Lösung**: Die Ausleihe muss mit einem vollständigen Datum+Uhrzeit erstellt werden.
---
## 🚀 AUTOMATISCHE AKTIVIERUNG TESTEN
Nach dem Fix: Erstelle eine Ausleihe für:
- **Heute** (das aktuelle Datum)
- **Schulstunde 7** (13:30-14:15)
- Startzeitpunkt sollte **jetzt oder in 1 Minute** sein
Warte 2 Minuten. Der Status sollte von `planned` zu `active` wechseln.
---
## 📊 SCHEDULER STATUS ÜBERPRÜFEN
```bash
# Zeige die letzten 50 Log-Zeilen
docker logs --tail 50 inventarsystem-app | tail -20
# Suche nach "Appointment status update"
docker logs inventarsystem-app 2>/dev/null | grep -i "Appointment status" | tail -5
```
Erwartete Log-Ausgabe:
```
Appointment status update finished: X changed (Y active, Z completed)
```
---
## 🔍 DETAILLIERTE DEBUG-INFOS
Wenn obiges nicht funktioniert, führe dies aus:
```bash
docker exec inventarsystem-app python3 -c "
from pymongo import MongoClient
import datetime
client = MongoClient('mongodb', 27017)
db = client['Inventarsystem']
aus = db['ausleihungen']
print('=== GEPLANTE AUSLEIHEN ===')
for b in aus.find({'Status': 'planned'}).limit(3):
print(f\"Start: {b.get('Start')} | Typ: {type(b.get('Start')).__name__}\")
print(f\"Periode: {b.get('Period')} | User: {b.get('User')}\")
print(f\"Jetzt > Start? {datetime.datetime.now() > b.get('Start', datetime.datetime.max)}\")
print()
"
```
---
## 💡 EMPFOHLENE EINSTELLUNG FÜR SCHULEN
In `config.json` verwende diese Werte:
```json
{
"scheduler": {
"enabled": true,
"interval_minutes": 1,
"backup_interval_hours": 24
}
}
```
Dies checkt **jede Minute** ob Termine aktiviert werden sollen - perfekt für Schulstunden.
---
## Weitere Hilfe?
Falls das nicht funktioniert:
1. Schreib mir die **exakte Uhrzeit** wann du die Ausleihe erstellt hast
2. Und wann sie hätte aktiviert werden sollen
3. Zeige mir ein Beispiel aus der DB mit `docker exec inventarsystem-mongodb mongosh...`
+442
View File
@@ -0,0 +1,442 @@
# Web Push Notifications für Inventarsystem
## Überblick
Web Push Notifications ermöglichen es, Benutzer über wichtige Ereignisse in Echtzeit zu benachrichtigen, auch wenn sie die Anwendung nicht aktiv nutzen. Diese Implementierung nutzt:
- **Service Workers** für Hintergrundprozesse und Offline-Unterstützung
- **Web Push API** für Benachrichtigungen auf Desktop und Mobil
- **MongoDB** zur Speicherung von Subscriptions
- **VAPID-Authentifizierung** für sichere Push-Kommunikation
## Architektur
```
┌─────────────────────────────────────────────────────┐
│ Browser / Client-Side │
├─────────────────────────────────────────────────────┤
│ • Service Worker (static/service-worker.js) │
│ • Push Notification Manager (js/push-notifications) │
│ • Web App Manifest (manifest.json) │
└─────────────────────────────────────────────────────┘
↕ (Push Subscriptions / Notifications)
┌─────────────────────────────────────────────────────┐
│ Server / Backend │
├─────────────────────────────────────────────────────┤
│ • Flask API Endpoints (/api/push/*) │
│ • Push Notification Manager (push_notifications.py)│
│ • MongoDB Collections (push_subscriptions) │
│ • VAPID Key Management │
└─────────────────────────────────────────────────────┘
↕ (VAPID-signed push messages)
┌─────────────────────────────────────────────────────┐
│ Push Service (Firebase, Web Push Service) │
├─────────────────────────────────────────────────────┤
│ • Stores subscriptions │
│ • Delivers push messages to browsers │
└─────────────────────────────────────────────────────┘
```
## Setup & Konfiguration
### 1. VAPID-Schlüssel generieren
VAPID-Schlüssel sind erforderlich für die Authentifizierung mit dem Push-Dienst:
```bash
cd /path/to/Inventarsystem
bash generate-vapid-keys.sh
```
Dies erzeugt ein Schlüsselpaar. **Speichern Sie den Private Key sicher!**
Beispielausgabe:
```
PUBLIC KEY (share with browsers):
BBxyz...xyz
PRIVATE KEY (keep secret!):
AAabc...abc
```
### 2. Umgebungsvariablen setzen
Speichern Sie die Schlüssel als Umgebungsvariablen:
```bash
export VAPID_PUBLIC_KEY="BBxyz...xyz"
export VAPID_PRIVATE_KEY="AAabc...abc"
export VAPID_SUBJECT="mailto:admin@inventarsystem.local"
```
Für Docker:
```bash
# In .env oder docker-compose.yml
VAPID_PUBLIC_KEY=BBxyz...xyz
VAPID_PRIVATE_KEY=AAabc...abc
VAPID_SUBJECT=mailto:admin@inventarsystem.local
```
### 3. Abhängigkeiten installieren
```bash
pip install -r requirements.txt
# oder
pip install pywebpush
```
### 4. MongoDB Collection initialisieren
Die `push_subscriptions` Collection wird automatisch erstellt beim ersten Speichern einer Subscription. Indizes werden automatisch erstellt durch:
```python
from Web.push_notifications import ensure_push_subscriptions_collection
ensure_push_subscriptions_collection()
```
## API Endpoints
### `POST /api/push/subscribe`
Speichert eine Notification Subscription des Benutzers.
**Request:**
```json
{
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BCOA...",
"auth": "kXA..."
}
}
}
```
**Response:**
```json
{
"success": true,
"message": "Successfully subscribed to push notifications"
}
```
---
### `POST /api/push/unsubscribe`
Deaktiviert eine Notification Subscription.
**Request:**
```json
{
"endpoint": "https://fcm.googleapis.com/fcm/send/..."
}
```
**Response:**
```json
{
"success": true,
"message": "Successfully unsubscribed from push notifications"
}
```
---
### `GET /api/push/subscriptions`
Listet alle aktiven Subscriptions des aktuellen Benutzers auf.
**Response:**
```json
{
"success": true,
"count": 2,
"subscriptions": [
{
"id": "507f1f77bcf86cd799439011",
"endpoint": "https://...",
"created_at": "2026-04-10T14:30:00",
"last_used": "2026-04-10T15:45:00",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)..."
}
]
}
```
---
### `GET /api/push/vapid-key`
Ruft den öffentlichen VAPID-Schlüssel ab (erforderlich für Browser).
**Response:**
```json
{
"success": true,
"vapid_key": "BBxyz...xyz"
}
```
---
### `POST /api/push/test` (Admin only)
Sendet eine Test-Benachrichtigung.
**Request:**
```json
{
"target_user": "username" // optional, default: current user
}
```
**Response:**
```json
{
"success": true,
"message": "Test push sent to 2 subscription(s)"
}
```
## Frontend Integration
### Aktivierung in Settings
Fügen Sie einen Container in Ihre Einstellungsseite ein:
```html
<div id="push-notification-settings"></div>
<script src="{{ url_for('static', filename='js/push-notifications.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
showPushNotificationSettings();
});
</script>
```
Dies zeigt einen Button und Subscription-Status an.
### Manuelle Steuerung
```javascript
// Initialisieren
await pushNotificationManager.init();
// Benachrichtigungen aktivieren
await pushNotificationManager.subscribe();
// Benachrichtigungen deaktivieren
await pushNotificationManager.unsubscribe();
// Status prüfen
const isSubscribed = await pushNotificationManager.isSubscribed();
// Test-Benachrichtigung senden (Admin)
await pushNotificationManager.sendTestNotification();
```
## Backend Integration
### Benachrichtigungen versenden
```python
from Web import push_notifications as pn
# Benachrichtigung an einen Benutzer
pn.send_push_notification(
'username',
'Titel',
'Nachricht',
url='/my_borrowed_items',
reference={'item_id': '123', 'type': 'borrowing'}
)
# Benachrichtigung an alle Admins
pn.send_push_to_all_admins(
'Admin Alert',
'Wichtiges Ereignis',
url='/main_admin'
)
```
### Integration mit Notification-System
Benachrichtigungen werden automatisch über Push versendet, wenn erstellt:
```python
# In app.py
_create_notification(
db,
audience='user',
notif_type='borrowing_activated',
title='Ausleihung aktiviert',
message='Ihre geplante Ausleihung ist jetzt aktiv',
target_user='john_doe',
reference={'url': '/my_borrowed_items', 'item_id': '123'}
)
# → Schreibt in DB + sendet Push-Benachrichtigung
```
## MongoDB Schema
### Collection: `push_subscriptions`
```json
{
"_id": ObjectId("..."),
"Username": "john_doe",
"Endpoint": "https://fcm.googleapis.com/fcm/send/...",
"Keys": {
"p256dh": "BCOA...",
"auth": "kXA..."
},
"SubscriptionHash": "abc123def456...",
"IsActive": true,
"CreatedAt": ISODate("2026-04-10T14:30:00Z"),
"LastUsed": ISODate("2026-04-10T15:45:00Z"),
"UserAgent": "Mozilla/5.0..."
}
```
**Indizes:**
- `Username` - Schnelle Abfrage nach Benutzer
- `{Username: 1, IsActive: 1}` - Abfrage aktiver Subscriptions
- `CreatedAt` - Zeitbasierte Bereinigung
- `SubscriptionHash` - Eindeutigkeit, Duplikat-Verhinderung
## Service Worker
### Funktionen
Die Service Worker (`static/service-worker.js`) behandelt:
1. **Push Events** - Empfängt und zeigt Benachrichtigungen an
2. **Click Handler** - Öffnet relevante URLs bei Benachrichtigungs-Klick
3. **Offline Caching** - Speichert statische Assets offline
4. **Background Sync** - Synchronisiert Daten im Hintergrund
5. **Installation** - Installiert sich selbst und lädt Cache vor
### Push-Payload-Format
```json
{
"title": "Ausleihung aktiviert",
"body": "Ihre geplante Ausleihung ist jetzt aktiv",
"icon": "/static/img/logo-192x192.png",
"badge": "/static/img/badge-72x72.png",
"tag": "notification-borrowing_activated",
"url": "/my_borrowed_items",
"reference": {
"item_id": "123",
"type": "borrowing"
}
}
```
## Troubleshooting
### Benachrichtigungen werden nicht empfangen
1. **VAPID-Schlüssel nicht gesetzt**
```bash
# Überprüfen
curl http://localhost:5000/api/push/vapid-key
# Sollte VAPID_PUBLIC_KEY zurückgeben, nicht "nicht konfiguriert"
```
2. **Service Worker nicht registriert**
- Browser DevTools → Application → Service Workers
- Sollte "activated and running" anzeigen
- Überprüfen Sie Console auf Fehler
3. **Notification Permission verweigert**
- Browser-Einstellungen überprüfen
- Site-Benachrichtigungsberechtigungen zurücksetzen
- `chrome://settings/content/notifications` (Chrome)
4. **No active subscriptions**
```bash
# MongoDB überprüfen
db.push_subscriptions.find({Username: "username"})
# Sollte aktive Subscriptions anzeigen
```
### Debug-Befehle
```bash
# Alle Subscriptions anzeigen
docker exec inventarsystem-mongodb mongosh --eval \
'db.push_subscriptions.find({}).pretty()' Inventarsystem
# Test-Push senden
curl -X POST http://localhost:5000/api/push/test \
-H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt
# Logs überprüfen
docker logs inventarsystem-app | grep -i "push\|notification"
```
## Browser-Unterstützung
| Browser | Desktop | Mobile | Service Worker | Push API |
|---------|---------|--------|-----------------|----------|
| Chrome | ✅ | ✅ | ✅ | ✅ |
| Firefox | ✅ | ✅ | ✅ | ✅ |
| Safari | ⚠️ | ✅ | ⚠️ | ⚠️ |
| Edge | ✅ | ✅ | ✅ | ✅ |
**Safari-Hinweis:** Verwendet Web Push über APNs mit Einschränkungen.
## Best Practices
### 1. Notification Häufigkeit
- Nicht mehr als 1 Benachrichtigung pro Minute pro Benutzer
- Sammelns Sie verwandte Events
### 2. Payload-Größe
- Halten Sie Nachrichten kurz (<100 Zeichen)
- Verwenden Sie `reference` für Kontext, nicht `body`
### 3. Sicherheit
- **Private Key**: Niemals in Code commiten!
- **Secrets**: Nur als Umgebungsvariablen speichern
- **Validate**: Server-seitig alle Subscription-Daten validieren
### 4. Datenschutz
- Dokumentieren Sie Push-Sammlung (DSGVO)
- Bieten Sie einfache Opt-out-Möglichkeit
- Speichern Sie keine PII in Push-Nachrichten
### 5. Wartung
Stale Subscriptions automatisch bereinigen:
```python
# In Scheduler oder Cron-Job
from Web.push_notifications import cleanup_inactive_subscriptions
cleanup_inactive_subscriptions() # Entfernt inaktive Subs älter als 30 Tage
```
## Performance-Tipps
1. **Batch-Sends**: Senden Sie mehrere Pushes in einem Query
2. **Async**: Verwenden Sie Background Tasks für Push-Sending
3. **Redis**: Nutzen Sie Cache für häufig angeforderte VAPID-Keys
4. **Indexes**: MongoDB-Indexes auf `Username`, `IsActive` sollten vorhanden sein
## Zukünftige Erweiterungen
- [ ] Action Buttons in Benachrichtigungen (Approve/Deny)
- [ ] Benachrichtigungs-Kategorien und Gruppierung
- [ ] Rich-Media-Unterstützung (Bilder, Video)
- [ ] Scheduled Notifications
- [ ] Analytics & Delivery Tracking
---
**Version:** 1.0 (Inventarsystem v0.5+)
**Letzte Aktualisierung:** April 2026
Binary file not shown.
Binary file not shown.
+294 -7
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
@@ -1196,8 +1219,21 @@ def update_appointment_statuses():
updated_count += 1
if new_status == 'active':
activated_count += 1
# Make item unshareable if no conflict is detected
if old_status == 'planned' and appointment.get('Item') and not extra_fields.get('ConflictDetected', False):
try:
it.update_item_status(str(appointment.get('Item')), False, activation_user)
except Exception as e:
app.logger.warning(f"Could not update item status to False for {appointment['_id']}: {e}")
elif new_status == 'completed':
completed_count += 1
# Make item available again
if appointment.get('Item'):
try:
it.update_item_status(str(appointment.get('Item')), True)
except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
# Create activation notification even if another worker already updated the status.
if old_status == 'planned' and new_status == 'active' and activation_user:
@@ -1246,9 +1282,17 @@ def _initialize_scheduler():
return
try:
# For multi-worker Gunicorn, check if we're in a reasonable scenario
# Using a lock file to ensure only one instance starts the scheduler
# For multi-worker Gunicorn, use a lock file to ensure only one instance starts the scheduler
# Clean up any stale lock file from previous runs (older than 5 minutes)
scheduler_lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
if os.path.exists(scheduler_lock_path):
lock_age = time.time() - os.path.getmtime(scheduler_lock_path)
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception:
pass # If we can't clean up, continue anyway
try:
# Try to create the lock file - only succeeds if it doesn't exist
@@ -9014,8 +9058,7 @@ def my_borrowed_items():
planned_ausleihungen = list(ausleihungen_collection.find({
'User': username,
'Status': 'planned',
'Start': {'$gt': current_time}
'Status': 'planned'
}))
# DEBUG: Log the number of planned appointments found
@@ -9647,21 +9690,76 @@ def schedule_appointment():
print(f"Error checking for booking conflicts: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500
# Create the appointment as a planned booking
# Check if the appointment should already be active
now = datetime.datetime.now()
initial_status = 'active' if start_datetime <= now else 'planned'
# Create the appointment
try:
appointment_id = au.add_planned_booking(
# Use add_ausleihung directly to set the correct initial status
appointment_id = au.add_ausleihung(
item_id=item_id,
user=session['username'],
start_date=start_datetime,
end_date=end_datetime,
notes=notes,
status=initial_status,
period=booking_period # Will be None for multi-day
)
# If it became active immediately, log it and send a notification
if initial_status == 'active' and appointment_id:
app.logger.info(f"Appointment {appointment_id} scheduled retroactively as active.")
# Make the item unavailable since it is now actively borrowed
try:
it.update_item_status(item_id, False, session['username'])
except Exception as update_err:
app.logger.warning(f"Failed to update item status when retroactively activating: {update_err}")
# We can also notify the user right away
item_name = item.get('Name', 'Unbekannt')
# Log audit event
_append_audit_event_standalone(
'ausleihung_started',
{
'borrow_id': str(appointment_id),
'item_id': item_id,
'item_name': item_name,
'user': session['username'],
'status_before': 'planned',
'status_after': 'active'
}
)
# Send notification
try:
client_temp = MongoClient(MONGODB_HOST, MONGODB_PORT)
db_temp = client_temp[MONGODB_DB]
_create_notification(
db_temp,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=f"Deine geplante Ausleihe für {item_name} startet jetzt.",
target_user=session['username'],
reference={
'appointment_id': str(appointment_id),
'item_id': str(item_id),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment_id}",
severity='info'
)
client_temp.close()
except Exception as notif_err:
app.logger.error(f"Error sending immediate active notification: {notif_err}")
if not appointment_id:
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
except Exception as e:
print(f"Error creating planned booking: {e}")
print(f"Error creating booking: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500
# If we got this far, we have a valid appointment_id
@@ -9748,6 +9846,16 @@ def cancel_ausleihung_route(id):
if au.cancel_ausleihung(id):
print(f"Successfully canceled ausleihung with ID: {id}")
flash('Ausleihung wurde erfolgreich storniert', 'success')
# If the booking was already active, make the item available again
item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None
if ausleihung_status == 'active' and item_id:
try:
it.update_item_status(item_id, True)
print(f"Restored availability of item {item_id} after active cancellation")
except Exception as status_err:
print(f"Warning: could not restore availability of item {item_id}: {status_err}")
_append_audit_event_standalone(
'ausleihung_cancelled',
{
@@ -10655,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
+5
View File
@@ -85,6 +85,11 @@ def get_current_status(ausleihung, log_changes=False, user=None):
new_status = 'completed'
# Wenn die aktuelle Zeit vor dem Startdatum liegt, ist die Ausleihung geplant
elif current_time < start_time:
# DEBUG: Log info wenn Booking noch lange in der Zukunft ist
time_until_start = (start_time - current_time).total_seconds()
if time_until_start > 3600: # Mehr als 1 Stunde entfernt
ausleihung_id = str(ausleihung.get('_id', 'unknown'))[:12]
print(f"[DEBUG] Ausleihe {ausleihung_id} startet in {time_until_start/3600:.1f} Stunden ({start_time}), noch geplant")
new_status = 'planned'
# Wenn kein Enddatum gesetzt ist oder die aktuelle Zeit vor dem Enddatum liegt,
# ist die Ausleihung aktiv
+4 -2
View File
@@ -287,15 +287,17 @@ def update_item_status(id, verfuegbar, user=None):
'LastUpdated': datetime.datetime.now()
}
update_query = {'$set': update_data}
if user is not None:
update_data['User'] = user
elif verfuegbar:
# If item is being marked as available, clear the user field
update_data['$unset'] = {'User': ""}
update_query['$unset'] = {'User': ""}
result = items.update_one(
{'_id': ObjectId(id)},
{'$set': update_data}
update_query
)
client.close()
+421
View File
@@ -0,0 +1,421 @@
"""
Push Notification Management System
Handles Web Push notifications for users via Service Workers
"""
import os
import json
import datetime
from pymongo import MongoClient
from bson import ObjectId
import requests
import hashlib
import logging
import settings as cfg
logger = logging.getLogger(__name__)
# VAPID keys for push notifications (should be in environment variables)
VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '')
VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '')
VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', f'mailto:admin@{os.getenv("SERVER_NAME", "localhost")}')
# Push service endpoint (typically Firebase or Web Push Service)
PUSH_SERVICE_URL = 'https://fcm.googleapis.com/fcm/send' # Firebase Cloud Messaging
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key
def get_push_subscriptions_collection(db=None):
"""Get MongoDB push subscriptions collection"""
if db is None:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
return db['push_subscriptions']
def get_user_subscriptions(username):
"""
Get all active push subscriptions for a user
Args:
username (str): Username
Returns:
list: List of subscription documents
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
subscriptions = list(subs_col.find({
'Username': username,
'IsActive': True
}))
client.close()
return subscriptions
except Exception as e:
logger.error(f'Error getting push subscriptions for {username}: {e}')
return []
def save_push_subscription(username, subscription_obj):
"""
Save a new push subscription for a user
Args:
username (str): Username
subscription_obj (dict): Subscription object from Service Worker
{
'endpoint': 'https://...',
'keys': {
'p256dh': '...',
'auth': '...'
}
}
Returns:
bool: Success status
"""
try:
if not subscription_obj.get('endpoint'):
logger.warning(f'Invalid subscription object for {username}')
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
# Create unique hash of subscription to avoid duplicates
sub_hash = hashlib.md5(
f"{username}:{subscription_obj['endpoint']}".encode()
).hexdigest()
# Check if subscription already exists
existing = subs_col.find_one({
'Username': username,
'SubscriptionHash': sub_hash
})
if existing:
# Update last used time
subs_col.update_one(
{'_id': existing['_id']},
{'$set': {
'LastUsed': datetime.datetime.now(),
'IsActive': True
}}
)
logger.info(f'Updated existing subscription for {username}')
client.close()
return True
# Save new subscription
subscription_doc = {
'Username': username,
'Endpoint': subscription_obj['endpoint'],
'Keys': subscription_obj.get('keys', {}),
'SubscriptionHash': sub_hash,
'IsActive': True,
'CreatedAt': datetime.datetime.now(),
'LastUsed': datetime.datetime.now(),
'UserAgent': subscription_obj.get('userAgent', ''),
}
subs_col.insert_one(subscription_doc)
logger.info(f'Saved new push subscription for {username}')
client.close()
return True
except Exception as e:
logger.error(f'Error saving push subscription for {username}: {e}')
return False
def remove_push_subscription(username, endpoint):
"""
Remove a push subscription
Args:
username (str): Username
endpoint (str): Subscription endpoint URL
Returns:
bool: Success status
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
result = subs_col.update_one(
{
'Username': username,
'Endpoint': endpoint
},
{'$set': {'IsActive': False}}
)
client.close()
return result.modified_count > 0
except Exception as e:
logger.error(f'Error removing push subscription for {username}: {e}')
return False
def send_push_notification(username, title, body, icon=None, url='/', reference=None, tag='notification'):
"""
Send a push notification to all user's subscriptions
Args:
username (str): Target username
title (str): Notification title
body (str): Notification body
icon (str, optional): Icon URL
url (str, optional): URL to open on click
reference (dict, optional): Reference data (item_id, etc)
tag (str, optional): Notification tag for grouping
Returns:
int: Number of successfully sent notifications
"""
try:
subscriptions = get_user_subscriptions(username)
if not subscriptions:
logger.debug(f'No active push subscriptions for {username}')
return 0
sent_count = 0
for subscription in subscriptions:
success = _send_to_subscription(
subscription,
title,
body,
icon,
url,
reference,
tag
)
if success:
sent_count += 1
else:
# Mark subscription as inactive if send fails
_mark_subscription_inactive(subscription['_id'])
logger.info(f'Sent push notification to {username}: {sent_count}/{len(subscriptions)} subscriptions')
return sent_count
except Exception as e:
logger.error(f'Error sending push notification to {username}: {e}')
return 0
def _send_to_subscription(subscription, title, body, icon, url, reference, tag):
"""Send push notification to a specific subscription"""
try:
payload = {
'title': title,
'body': body,
'icon': icon or '/static/img/logo-192x192.png',
'badge': '/static/img/badge-72x72.png',
'tag': tag,
'url': url,
'reference': reference or {},
}
# If using Firebase Cloud Messaging
if FCM_API_KEY and subscription.get('Endpoint', '').startswith('https://fcm.'):
return _send_fcm_notification(subscription, payload)
# Otherwise use standard Web Push Protocol
return _send_web_push_notification(subscription, payload)
except Exception as e:
logger.error(f'Error sending to subscription {subscription.get("_id")}: {e}')
return False
def _send_fcm_notification(subscription, payload):
"""Send notification via Firebase Cloud Messaging"""
try:
if not FCM_API_KEY:
logger.warning('FCM_API_KEY not configured')
return False
fcm_payload = {
'to': subscription['Endpoint'],
'notification': {
'title': payload['title'],
'body': payload['body'],
'icon': payload['icon'],
'badge': payload['badge'],
'tag': payload['tag'],
'click_action': payload['url'],
},
'data': {
'url': payload['url'],
'type': payload.get('type', 'info'),
}
}
headers = {
'Authorization': f'key={FCM_API_KEY}',
'Content-Type': 'application/json'
}
response = requests.post(
'https://fcm.googleapis.com/fcm/send',
json=fcm_payload,
headers=headers,
timeout=10
)
return response.status_code == 200
except Exception as e:
logger.error(f'FCM notification error: {e}')
return False
def _send_web_push_notification(subscription, payload):
"""Send notification using standard Web Push Protocol"""
try:
# This requires pywebpush library
from pywebpush import webpush
webpush(
subscription_info={
'endpoint': subscription['Endpoint'],
'keys': subscription.get('Keys', {})
},
data=json.dumps(payload),
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT},
timeout=10
)
return True
except ImportError:
logger.warning('pywebpush not installed, install with: pip install pywebpush')
return False
except Exception as e:
logger.error(f'Web push error: {e}')
return False
def _mark_subscription_inactive(subscription_id):
"""Mark a subscription as inactive (e.g., after failed send)"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
subs_col.update_one(
{'_id': ObjectId(subscription_id)},
{'$set': {'IsActive': False}}
)
client.close()
except Exception as e:
logger.error(f'Error marking subscription inactive: {e}')
def send_push_to_all_admins(title, body, icon=None, url='/', reference=None):
"""
Send a push notification to all admin users
Args:
title (str): Notification title
body (str): Notification body
icon (str, optional): Icon URL
url (str, optional): URL to open on click
reference (dict, optional): Reference data
Returns:
int: Total notifications sent
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users_col = db['users']
# Get all admin users
admin_users = list(users_col.find(
{'IsAdmin': True},
{'Username': 1}
))
client.close()
total_sent = 0
for admin_doc in admin_users:
sent = send_push_notification(
admin_doc['Username'],
title,
body,
icon,
url,
reference,
tag='admin-notification'
)
total_sent += sent
logger.info(f'Sent push to all admins: {total_sent} notifications')
return total_sent
except Exception as e:
logger.error(f'Error sending push to admins: {e}')
return 0
def cleanup_inactive_subscriptions():
"""
Remove inactive subscriptions older than 30 days
Run this periodically as a maintenance task
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=30)
result = subs_col.delete_many({
'IsActive': False,
'LastUsed': {'$lt': cutoff_date}
})
client.close()
logger.info(f'Cleaned up {result.deleted_count} inactive subscriptions')
return result.deleted_count
except Exception as e:
logger.error(f'Error cleaning up subscriptions: {e}')
return 0
# Database collection schema
def ensure_push_subscriptions_collection():
"""Ensure the push_subscriptions collection exists with proper indexes"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db)
# Create indexes
subs_col.create_index('Username')
subs_col.create_index([('Username', 1), ('IsActive', 1)])
subs_col.create_index([('CreatedAt', 1)]) # TTL-like usage
subs_col.create_index('SubscriptionHash', unique=True)
logger.info('Push subscriptions collection indexes created')
client.close()
except Exception as e:
logger.error(f'Error ensuring push subscriptions collection: {e}')
+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');
});
})
);
});
+24
View File
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css', v=ASSET_VERSION) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=ASSET_VERSION) }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
@@ -1752,5 +1753,28 @@
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
<!-- Service Worker Registration for Push Notifications -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Register service worker for push notifications and offline support
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{{ url_for("static", filename="service-worker.js") }}')
.then(function(registration) {
console.log('Service Worker registered successfully:', registration);
// Check if we have permission for push notifications
if ('Notification' in window && Notification.permission === 'granted') {
console.log('Push notifications already enabled');
}
})
.catch(function(error) {
console.warn('Service Worker registration failed:', error);
});
} else {
console.log('Service Workers not supported in this browser');
}
});
</script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Debug script to check why planned bookings are not being activated
"""
import sys
import os
import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Web'))
from pymongo import MongoClient
import settings as cfg
import ausleihung as au
def check_bookings():
"""Check all planned bookings and their status"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
# Get all planned bookings
planned = list(ausleihungen.find({'Status': 'planned'}))
print(f"\n📊 Gefundene GEPLANTE Ausleihen: {len(planned)}\n")
print("=" * 100)
current_time = datetime.datetime.now()
print(f"⏰ Aktuelle Zeit: {current_time}\n")
for booking in planned:
booking_id = str(booking['_id'])
user = booking.get('User', 'N/A')
item = booking.get('Item', 'N/A')
start = booking.get('Start')
end = booking.get('End')
period = booking.get('Period')
status = booking.get('Status')
print(f"\n🔹 Ausleihe ID: {booking_id}")
print(f" Benutzer: {user}")
print(f" Item: {item}")
print(f" Status: {status}")
print(f" Schulstunde/Periode: {period}")
print(f" Start: {start} (Typ: {type(start).__name__})")
print(f" Ende: {end} (Typ: {type(end).__name__})")
# Check current status
current_status = au.get_current_status(booking, log_changes=False)
print(f" ✓ Berechneter Status: {current_status}")
if current_status != status:
print(f" ⚠️ STATUS STIMMT NICHT ÜBEREIN! Sollte sein: {current_status}")
# Check time comparison
if start:
time_diff = (current_time - start).total_seconds()
if time_diff < 0:
print(f" ⏳ Startet in: {abs(time_diff) / 60:.1f} Minuten")
elif time_diff < 3600:
print(f" ✓ Sollte JETZT AKTIV sein! ({time_diff / 60:.1f} Minuten vorbei)")
else:
print(f" ✓ Sollte schon {time_diff / 3600:.1f} Stunden aktiv sein!")
print("\n" + "=" * 100)
# Check active bookings too
active = list(ausleihungen.find({'Status': 'active'}))
print(f"\n✓ AKTIVE Ausleihen: {len(active)}")
for booking in active:
print(f" - {booking.get('User', 'N/A')}: {booking.get('Item', 'N/A')} (Periode: {booking.get('Period')})")
client.close()
if __name__ == '__main__':
check_bookings()
+4 -2
View File
@@ -16,8 +16,10 @@ services:
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
- redis
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
+2 -1
View File
@@ -4,7 +4,8 @@ services:
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
app:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# VAPID Key Generation Script for Web Push Notifications
# Generates VAPID (Voluntary Application Server Identification) keys required for push notifications
echo "==========================================="
echo "VAPID Key Generation for Inventarsystem"
echo "==========================================="
echo ""
# Check if Python is available
if ! command -v python3 &> /dev/null; then
echo "❌ Error: Python 3 is required but not installed."
exit 1
fi
# Check if pywebpush is installed
echo "Checking for pywebpush..."
python3 -c "import pywebpush" 2>/dev/null
if [ $? -ne 0 ]; then
echo "Installing pywebpush..."
pip3 install pywebpush
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to install pywebpush"
echo "Please install manually: pip3 install pywebpush"
exit 1
fi
fi
echo "✓ pywebpush is installed"
echo ""
# Generate VAPID keys
echo "Generating VAPID keys..."
python3 << 'EOF'
from pywebpush import generate_keys
try:
keys = generate_keys()
print("✓ VAPID Keys generated successfully!")
print("")
print("PUBLIC KEY (share with browsers):")
print(keys['public_key'])
print("")
print("PRIVATE KEY (keep secret!):")
print(keys['private_key'])
print("")
print("==========================================="
print("Add these to your environment variables:")
print("==========================================="
print("")
print("export VAPID_PUBLIC_KEY='" + keys['public_key'] + "'")
print("export VAPID_PRIVATE_KEY='" + keys['private_key'] + "'")
print("export VAPID_SUBJECT='mailto:admin@yourdomain.com'")
print("")
print("Or add to your .env file:")
print("")
print("VAPID_PUBLIC_KEY=" + keys['public_key'])
print("VAPID_PRIVATE_KEY=" + keys['private_key'])
print("VAPID_SUBJECT=mailto:admin@yourdomain.com")
print("")
print("⚠️ IMPORTANT: Keep the PRIVATE KEY secret!")
print("==========================================="
except Exception as e:
print(f"❌ Error generating VAPID keys: {e}")
exit(1)
EOF
if [ $? -eq 0 ]; then
echo ""
echo "✓ Next steps:"
echo "1. Copy the PUBLIC KEY to your browser-side code"
echo "2. Set the PRIVATE KEY in your server environment"
echo "3. Update config.json with your email address"
echo "4. Test with: curl http://localhost:5000/api/push/vapid-key"
else
echo "❌ Error generating keys"
exit 1
fi
+68 -28
View File
@@ -68,45 +68,85 @@ with open(compose_file, "r", encoding="utf-8") as f:
lines = f.readlines()
out = []
in_app = False
in_build = False
image_set = False
in_app_service = False
app_indent = None
app_service_indent = None
build_found = False
for line in lines:
def leading_spaces(text):
return len(text) - len(text.lstrip(" "))
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
indent = leading_spaces(line)
if not in_app and re.match(r"^\s{2}app:\s*$", line):
in_app = True
image_set = False
# Check if we're starting the app service
if not in_app_service and re.match(r"^\s*app:\s*$", line):
in_app_service = True
app_indent = indent
app_service_indent = None
build_found = False
out.append(line)
out.append(f" image: {target_image}\n")
image_set = True
i += 1
continue
if in_app:
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
in_app = False
in_build = False
# Check if we've exited the app service (found another top-level service)
if in_app_service and indent <= app_indent and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
# We've left the app service - insert image if we haven't already
if build_found and app_service_indent is not None:
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
in_app_service = False
out.append(line)
i += 1
continue
if in_app:
if in_build:
if indent > 4:
continue
in_build = False
# Process lines within the app service
if in_app_service:
if app_service_indent is None and indent > app_indent:
app_service_indent = indent
if re.match(r"^\s{4}build:\s*$", line):
in_build = True
continue
# Check for image key (already has an image, don't add)
if re.match(rf"^\s+image:\s*", line):
in_app_service = False
out.append(line)
i += 1
continue
if re.match(r"^\s{4}image:\s*", line):
if image_set:
continue
out.append(f" image: {target_image}\n")
image_set = True
continue
# Check for build block
if re.match(rf"^\s+build:\s*$", line):
build_found = True
out.append(line)
i += 1
# Skip all lines that are part of the build block (indented more than app_service_indent)
while i < len(lines):
next_line = lines[i]
next_indent = leading_spaces(next_line)
if next_indent > app_service_indent and next_line.strip():
out.append(next_line)
i += 1
else:
break
continue
# Insert image after build block before first property
if build_found and app_service_indent is not None and indent == app_service_indent:
# Check if this is a property line (not build)
if not re.match(rf"^\s+build:", line):
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
build_found = False # Mark that we've inserted the image
out.append(line)
i += 1
continue
out.append(line)
i += 1
# If we ended while still in the app service, append image at the end
if in_app_service and build_found and app_service_indent is not None:
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
+2 -1
View File
@@ -12,4 +12,5 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography
pywebpush