📝 "Refactor backend
This commit is contained in:
parent
f5a39a450a
commit
831caec87a
308
backend/app.py
308
backend/app.py
@ -4828,34 +4828,32 @@ def session_status():
|
||||
@app.route('/api/session/extend', methods=['POST'])
|
||||
@login_required
|
||||
def extend_session():
|
||||
"""
|
||||
Verlängert die aktuelle Session um weitere Zeit.
|
||||
"""
|
||||
"""Verlängert die aktuelle Session um die Standard-Lebensdauer"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
extend_minutes = data.get('extend_minutes', 30)
|
||||
# Session-Lebensdauer zurücksetzen
|
||||
session.permanent = True
|
||||
|
||||
# Begrenzen der Verlängerung (max 2 Stunden)
|
||||
extend_minutes = min(extend_minutes, 120)
|
||||
# Aktivität für Rate Limiting aktualisieren
|
||||
current_user.update_last_activity()
|
||||
|
||||
now = datetime.now()
|
||||
session['last_activity'] = now.isoformat()
|
||||
session['session_extended'] = now.isoformat()
|
||||
session['extended_by_minutes'] = extend_minutes
|
||||
# Optional: Session-Statistiken für Admin
|
||||
user_agent = request.headers.get('User-Agent', 'Unknown')
|
||||
ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
||||
|
||||
auth_logger.info(f"🕒 Session verlängert für Benutzer {current_user.email} um {extend_minutes} Minuten")
|
||||
app_logger.info(f"Session verlängert für User {current_user.id} (IP: {ip_address})")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Session um {extend_minutes} Minuten verlängert",
|
||||
"extended_until": (now + timedelta(minutes=extend_minutes)).isoformat(),
|
||||
"extended_minutes": extend_minutes
|
||||
'success': True,
|
||||
'message': 'Session erfolgreich verlängert',
|
||||
'expires_at': (datetime.now() + SESSION_LIFETIME).isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
auth_logger.error(f"Fehler beim Verlängern der Session: {str(e)}")
|
||||
return jsonify({"error": "Session-Verlängerung fehlgeschlagen"}), 500
|
||||
|
||||
|
||||
app_logger.error(f"Fehler beim Verlängern der Session: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Fehler beim Verlängern der Session'
|
||||
}), 500
|
||||
|
||||
# ===== GASTANTRÄGE API-ROUTEN =====
|
||||
|
||||
@ -5669,3 +5667,273 @@ if __name__ == "__main__":
|
||||
except:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
# ===== AUTO-OPTIMIERUNG-API-ENDPUNKTE =====
|
||||
|
||||
@app.route('/api/optimization/auto-optimize', methods=['POST'])
|
||||
@login_required
|
||||
def auto_optimize_jobs():
|
||||
"""
|
||||
Automatische Optimierung der Druckaufträge durchführen
|
||||
Implementiert intelligente Job-Verteilung basierend auf verschiedenen Algorithmen
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
settings = data.get('settings', {})
|
||||
enabled = data.get('enabled', False)
|
||||
|
||||
db_session = get_db_session()
|
||||
|
||||
# Aktuelle Jobs in der Warteschlange abrufen
|
||||
pending_jobs = db_session.query(Job).filter(
|
||||
Job.status.in_(['queued', 'pending'])
|
||||
).all()
|
||||
|
||||
if not pending_jobs:
|
||||
db_session.close()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Keine Jobs zur Optimierung verfügbar',
|
||||
'optimized_jobs': 0
|
||||
})
|
||||
|
||||
# Verfügbare Drucker abrufen
|
||||
available_printers = db_session.query(Printer).filter(Printer.active == True).all()
|
||||
|
||||
if not available_printers:
|
||||
db_session.close()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Keine verfügbaren Drucker für Optimierung'
|
||||
})
|
||||
|
||||
# Optimierungs-Algorithmus anwenden
|
||||
algorithm = settings.get('algorithm', 'round_robin')
|
||||
optimized_count = 0
|
||||
|
||||
if algorithm == 'round_robin':
|
||||
optimized_count = apply_round_robin_optimization(pending_jobs, available_printers, db_session)
|
||||
elif algorithm == 'load_balance':
|
||||
optimized_count = apply_load_balance_optimization(pending_jobs, available_printers, db_session)
|
||||
elif algorithm == 'priority_based':
|
||||
optimized_count = apply_priority_optimization(pending_jobs, available_printers, db_session)
|
||||
|
||||
db_session.commit()
|
||||
jobs_logger.info(f"Auto-Optimierung durchgeführt: {optimized_count} Jobs optimiert mit Algorithmus {algorithm}")
|
||||
|
||||
# System-Log erstellen
|
||||
log_entry = SystemLog(
|
||||
level='INFO',
|
||||
component='optimization',
|
||||
message=f'Auto-Optimierung durchgeführt: {optimized_count} Jobs optimiert',
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
details=json.dumps({
|
||||
'algorithm': algorithm,
|
||||
'optimized_jobs': optimized_count,
|
||||
'settings': settings
|
||||
})
|
||||
)
|
||||
db_session.add(log_entry)
|
||||
db_session.commit()
|
||||
db_session.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'optimized_jobs': optimized_count,
|
||||
'algorithm': algorithm,
|
||||
'message': f'Optimierung erfolgreich: {optimized_count} Jobs wurden optimiert'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app_logger.error(f"Fehler bei der Auto-Optimierung: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Optimierung fehlgeschlagen: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/api/optimization/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def optimization_settings():
|
||||
"""Optimierungs-Einstellungen abrufen und speichern"""
|
||||
db_session = get_db_session()
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
# Standard-Einstellungen oder benutzerdefinierte laden
|
||||
default_settings = {
|
||||
'algorithm': 'round_robin',
|
||||
'consider_distance': True,
|
||||
'minimize_changeover': True,
|
||||
'max_batch_size': 10,
|
||||
'time_window': 24,
|
||||
'auto_optimization_enabled': False
|
||||
}
|
||||
|
||||
# Benutzereinstellungen aus der Session laden oder Standardwerte verwenden
|
||||
user_settings = session.get('user_settings', {})
|
||||
optimization_settings = user_settings.get('optimization', default_settings)
|
||||
|
||||
# Sicherstellen, dass alle erforderlichen Schlüssel vorhanden sind
|
||||
for key, value in default_settings.items():
|
||||
if key not in optimization_settings:
|
||||
optimization_settings[key] = value
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'settings': optimization_settings
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app_logger.error(f"Fehler beim Abrufen der Optimierungs-Einstellungen: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Fehler beim Laden der Einstellungen'
|
||||
}), 500
|
||||
|
||||
elif request.method == 'POST':
|
||||
try:
|
||||
settings = request.get_json()
|
||||
|
||||
# Validierung der Einstellungen
|
||||
if not validate_optimization_settings(settings):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Ungültige Optimierungs-Einstellungen'
|
||||
}), 400
|
||||
|
||||
# Einstellungen in der Session speichern
|
||||
user_settings = session.get('user_settings', {})
|
||||
if 'optimization' not in user_settings:
|
||||
user_settings['optimization'] = {}
|
||||
|
||||
# Aktualisiere die Optimierungseinstellungen
|
||||
user_settings['optimization'].update(settings)
|
||||
session['user_settings'] = user_settings
|
||||
|
||||
# Einstellungen in der Datenbank speichern, wenn möglich
|
||||
if hasattr(current_user, 'settings'):
|
||||
import json
|
||||
current_user.settings = json.dumps(user_settings)
|
||||
current_user.updated_at = datetime.now()
|
||||
db_session.commit()
|
||||
|
||||
app_logger.info(f"Optimierungs-Einstellungen für Benutzer {current_user.id} aktualisiert")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Optimierungs-Einstellungen erfolgreich gespeichert'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
app_logger.error(f"Fehler beim Speichern der Optimierungs-Einstellungen: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Fehler beim Speichern der Einstellungen: {str(e)}'
|
||||
}), 500
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
# ===== OPTIMIERUNGS-ALGORITHMUS-FUNKTIONEN =====
|
||||
|
||||
def apply_round_robin_optimization(jobs, printers, db_session):
|
||||
"""
|
||||
Round-Robin-Optimierung: Gleichmäßige Verteilung der Jobs auf Drucker
|
||||
Verteilt Jobs nacheinander auf verfügbare Drucker für optimale Balance
|
||||
"""
|
||||
optimized_count = 0
|
||||
printer_index = 0
|
||||
|
||||
for job in jobs:
|
||||
if printer_index >= len(printers):
|
||||
printer_index = 0
|
||||
|
||||
# Job dem nächsten Drucker zuweisen
|
||||
job.printer_id = printers[printer_index].id
|
||||
job.assigned_at = datetime.now()
|
||||
optimized_count += 1
|
||||
printer_index += 1
|
||||
|
||||
return optimized_count
|
||||
|
||||
def apply_load_balance_optimization(jobs, printers, db_session):
|
||||
"""
|
||||
Load-Balancing-Optimierung: Jobs basierend auf aktueller Auslastung verteilen
|
||||
Berücksichtigt die aktuelle Drucker-Auslastung für optimale Verteilung
|
||||
"""
|
||||
optimized_count = 0
|
||||
|
||||
# Aktuelle Drucker-Auslastung berechnen
|
||||
printer_loads = {}
|
||||
for printer in printers:
|
||||
current_jobs = db_session.query(Job).filter(
|
||||
Job.printer_id == printer.id,
|
||||
Job.status.in_(['running', 'queued'])
|
||||
).count()
|
||||
printer_loads[printer.id] = current_jobs
|
||||
|
||||
for job in jobs:
|
||||
# Drucker mit geringster Auslastung finden
|
||||
min_load_printer_id = min(printer_loads, key=printer_loads.get)
|
||||
|
||||
job.printer_id = min_load_printer_id
|
||||
job.assigned_at = datetime.now()
|
||||
|
||||
# Auslastung für nächste Iteration aktualisieren
|
||||
printer_loads[min_load_printer_id] += 1
|
||||
optimized_count += 1
|
||||
|
||||
return optimized_count
|
||||
|
||||
def apply_priority_optimization(jobs, printers, db_session):
|
||||
"""
|
||||
Prioritätsbasierte Optimierung: Jobs nach Priorität und verfügbaren Druckern verteilen
|
||||
Hochpriorisierte Jobs erhalten bevorzugte Druckerzuweisung
|
||||
"""
|
||||
optimized_count = 0
|
||||
|
||||
# Jobs nach Priorität sortieren
|
||||
priority_order = {'urgent': 1, 'high': 2, 'normal': 3, 'low': 4}
|
||||
sorted_jobs = sorted(jobs, key=lambda j: priority_order.get(getattr(j, 'priority', 'normal'), 3))
|
||||
|
||||
# Hochpriorisierte Jobs den besten verfügbaren Druckern zuweisen
|
||||
printer_assignments = {printer.id: 0 for printer in printers}
|
||||
|
||||
for job in sorted_jobs:
|
||||
# Drucker mit geringster Anzahl zugewiesener Jobs finden
|
||||
best_printer_id = min(printer_assignments, key=printer_assignments.get)
|
||||
|
||||
job.printer_id = best_printer_id
|
||||
job.assigned_at = datetime.now()
|
||||
|
||||
printer_assignments[best_printer_id] += 1
|
||||
optimized_count += 1
|
||||
|
||||
return optimized_count
|
||||
|
||||
def validate_optimization_settings(settings):
|
||||
"""
|
||||
Validiert die Optimierungs-Einstellungen auf Korrektheit und Sicherheit
|
||||
Verhindert ungültige Parameter die das System beeinträchtigen könnten
|
||||
"""
|
||||
try:
|
||||
# Algorithmus validieren
|
||||
valid_algorithms = ['round_robin', 'load_balance', 'priority_based']
|
||||
if settings.get('algorithm') not in valid_algorithms:
|
||||
return False
|
||||
|
||||
# Numerische Werte validieren
|
||||
max_batch_size = settings.get('max_batch_size', 10)
|
||||
if not isinstance(max_batch_size, int) or max_batch_size < 1 or max_batch_size > 50:
|
||||
return False
|
||||
|
||||
time_window = settings.get('time_window', 24)
|
||||
if not isinstance(time_window, int) or time_window < 1 or time_window > 168:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ===== GASTANTRÄGE API-ROUTEN =====
|
Binary file not shown.
BIN
backend/database/myp.db-shm
Normal file
BIN
backend/database/myp.db-shm
Normal file
Binary file not shown.
BIN
backend/database/myp.db-wal
Normal file
BIN
backend/database/myp.db-wal
Normal file
Binary file not shown.
290
backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md
Normal file
290
backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md
Normal file
@ -0,0 +1,290 @@
|
||||
# Auto-Optimierung mit belohnendem Modal - Fehlerbehebung und Verbesserungen
|
||||
|
||||
## 📋 Übersicht
|
||||
|
||||
Dieses Dokument beschreibt die implementierten Verbesserungen für die Auto-Optimierung-Funktion der MYP-Plattform, einschließlich der Fehlerbehebung und der Hinzufügung eines belohnenden animierten Modals.
|
||||
|
||||
## 🐛 Behobene Fehler
|
||||
|
||||
### Problem 1: 404 Fehler bei Auto-Optimierung
|
||||
**Symptom:** `POST http://127.0.0.1:5000/api/optimization/auto-optimize 404 (NOT FOUND)`
|
||||
|
||||
**Ursache:** Der API-Endpunkt `/api/optimization/auto-optimize` war nicht in der aktuellen `app.py` implementiert.
|
||||
|
||||
**Lösung:**
|
||||
- Hinzufügung des fehlenden Endpunkts zur `app.py`
|
||||
- Implementierung der unterstützenden Optimierungs-Algorithmus-Funktionen
|
||||
- Vollständige Cascade-Analyse durchgeführt
|
||||
|
||||
### Problem 2: JSON-Parsing-Fehler
|
||||
**Symptom:** `SyntaxError: Unexpected token '<', "<!DOCTYPE h..."`
|
||||
|
||||
**Ursache:** Frontend erwartete JSON-Antwort, erhielt aber HTML-Fehlerseite.
|
||||
|
||||
**Lösung:**
|
||||
- Korrekte JSON-Responses implementiert
|
||||
- Robuste Fehlerbehandlung hinzugefügt
|
||||
- CSRF-Token-Handling verbessert
|
||||
|
||||
## 🚀 Neue Features
|
||||
|
||||
### 1. Belohnendes Animiertes Modal
|
||||
|
||||
Das neue Modal-System bietet ein außergewöhnlich motivierendes Benutzererlebnis:
|
||||
|
||||
#### Features:
|
||||
- **Dynamische Erfolgsmeldungen** basierend auf Anzahl optimierter Jobs
|
||||
- **Konfetti-Animation** mit fallenden bunten Partikeln
|
||||
- **Animierte Emojis** mit pulsierenden und schwebenden Effekten
|
||||
- **Erfolgsstatistiken** mit animierten Zählern
|
||||
- **Belohnungs-Badge** mit Glow-Effekt
|
||||
- **Audio-Feedback** (optional, browserabhängig)
|
||||
- **Auto-Close** nach 10 Sekunden
|
||||
|
||||
#### Animationen:
|
||||
```css
|
||||
- Fade-in: Sanftes Einblenden des Modals
|
||||
- Bounce-in: Federnder Eingang der Modal-Box
|
||||
- Pulse-scale: Pulsierende Emoji-Animationen
|
||||
- Float: Schwebende Sterne-Effekte
|
||||
- Slide-up: Gestaffelte Einblend-Animationen
|
||||
- Count-up: Animierte Zahlen-Animation
|
||||
- Glow: Leuchtender Badge-Effekt
|
||||
- Confetti-fall: Fallende Konfetti-Partikel
|
||||
```
|
||||
|
||||
### 2. Ladeanimation
|
||||
|
||||
Während der Optimierung wird eine elegante Ladeanimation angezeigt:
|
||||
- Drehender Spinner mit Glow-Effekt
|
||||
- Motivierende Nachrichten
|
||||
- Backdrop-Blur für fokussierte Aufmerksamkeit
|
||||
|
||||
### 3. Audio-Feedback
|
||||
|
||||
Optionale Erfolgstöne werden über die Web Audio API generiert:
|
||||
- Harmonische Ton-Sequenz (C5 → E5 → G5)
|
||||
- Graceful Degradation bei nicht unterstützten Browsern
|
||||
|
||||
## 🛠️ Technische Implementierung
|
||||
|
||||
### Backend-Endpunkte
|
||||
|
||||
#### `/api/optimization/auto-optimize` (POST)
|
||||
**Beschreibung:** Führt automatische Job-Optimierung durch
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"algorithm": "round_robin|load_balance|priority_based",
|
||||
"consider_distance": true,
|
||||
"minimize_changeover": true,
|
||||
"max_batch_size": 10,
|
||||
"time_window": 24
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"optimized_jobs": 5,
|
||||
"algorithm": "round_robin",
|
||||
"message": "Optimierung erfolgreich: 5 Jobs wurden optimiert"
|
||||
}
|
||||
```
|
||||
|
||||
#### `/api/optimization/settings` (GET/POST)
|
||||
**Beschreibung:** Verwaltet Benutzer-Optimierungs-Einstellungen
|
||||
|
||||
### Optimierungs-Algorithmen
|
||||
|
||||
#### 1. Round Robin
|
||||
- **Prinzip:** Gleichmäßige Verteilung auf alle verfügbaren Drucker
|
||||
- **Verwendung:** Standard-Algorithmus für ausgewogene Auslastung
|
||||
|
||||
#### 2. Load Balancing
|
||||
- **Prinzip:** Berücksichtigt aktuelle Drucker-Auslastung
|
||||
- **Verwendung:** Optimiert für minimale Wartezeiten
|
||||
|
||||
#### 3. Priority-Based
|
||||
- **Prinzip:** Hochpriorisierte Jobs erhalten bevorzugte Drucker
|
||||
- **Verwendung:** Kritische Jobs werden priorisiert
|
||||
|
||||
### Frontend-Architektur
|
||||
|
||||
#### Klassenstruktur: OptimizationManager
|
||||
```javascript
|
||||
class OptimizationManager {
|
||||
// Kern-Funktionalitäten
|
||||
performAutoOptimization() // Führt Optimierung durch
|
||||
showRewardModal(data) // Zeigt Belohnungs-Modal
|
||||
generateConfetti() // Erzeugt Konfetti-Animation
|
||||
|
||||
// Ladezustände
|
||||
showOptimizationLoading() // Zeigt Ladeanimation
|
||||
hideOptimizationLoading() // Versteckt Ladeanimation
|
||||
|
||||
// Audio-Feedback
|
||||
playSuccessSound() // Spielt Erfolgston ab
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
### Mobile Optimierungen:
|
||||
- Reduzierte Konfetti-Größe auf kleineren Bildschirmen
|
||||
- Angepasste Emoji-Größen für Touch-Geräte
|
||||
- Vollbreite Modal-Darstellung auf mobilen Geräten
|
||||
|
||||
### CSS-Mediaqueries:
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
.confetti-piece { width: 6px; height: 6px; }
|
||||
#optimization-reward-modal .text-8xl { font-size: 4rem; }
|
||||
#optimization-reward-modal .max-w-md { max-width: 90vw; }
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Design-Prinzipien
|
||||
|
||||
### Belohnungspsychologie:
|
||||
1. **Sofortiges Feedback:** Modal erscheint unmittelbar nach Erfolg
|
||||
2. **Visuelle Verstärkung:** Größere Erfolge = spektakulärere Animationen
|
||||
3. **Fortschritts-Gefühl:** Statistiken zeigen konkrete Verbesserungen
|
||||
4. **Positive Verstärkung:** Motivierende Nachrichten und Belohnungs-Badges
|
||||
|
||||
### Animation-Timing:
|
||||
- **Eingangs-Animationen:** 0.3-0.6s für Aufmerksamkeit
|
||||
- **Kontinuierliche Animationen:** 2-3s für subtile Bewegung
|
||||
- **Ausgangs-Animationen:** 0.2-0.3s für sanftes Verschwinden
|
||||
|
||||
## 🔧 Wartung und Erweiterung
|
||||
|
||||
### Konfigurierbare Parameter:
|
||||
```javascript
|
||||
// In optimization-features.js
|
||||
const MODAL_AUTO_CLOSE_DELAY = 10000; // 10 Sekunden
|
||||
const CONFETTI_COUNT = 30; // Anzahl Konfetti-Partikel
|
||||
const AUDIO_ENABLED = true; // Audio-Feedback aktiviert
|
||||
```
|
||||
|
||||
### Erweiterungsmöglichkeiten:
|
||||
1. **Neue Algorithmen:** Hinzufügung in `apply_*_optimization` Funktionen
|
||||
2. **Mehr Animationen:** Erweiterung der CSS-Animationsbibliothek
|
||||
3. **Gamification:** Achievement-System für häufige Optimierungen
|
||||
4. **Personalisierung:** Benutzer-spezifische Animationseinstellungen
|
||||
|
||||
## 📊 Performance-Optimierungen
|
||||
|
||||
### CSS-Optimierungen:
|
||||
```css
|
||||
.animate-bounce-in,
|
||||
.animate-fade-in {
|
||||
will-change: transform, opacity; // GPU-Beschleunigung
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript-Optimierungen:
|
||||
- **Event-Delegation:** Effiziente Event-Handler
|
||||
- **Memory-Management:** Automatisches Cleanup von Modals
|
||||
- **Throttling:** Begrenzung der Optimierungs-Frequenz
|
||||
|
||||
## 🧪 Testing-Strategien
|
||||
|
||||
### Frontend-Tests:
|
||||
1. Modal-Erscheinung bei verschiedenen Erfolgszahlen
|
||||
2. Animation-Performance auf verschiedenen Geräten
|
||||
3. Graceful Degradation ohne JavaScript
|
||||
4. CSRF-Token-Validierung
|
||||
|
||||
### Backend-Tests:
|
||||
1. Algorithmus-Korrektheit mit verschiedenen Job-Verteilungen
|
||||
2. Fehlerbehandlung bei fehlenden Druckern
|
||||
3. Permissions-Validierung
|
||||
4. Performance bei hoher Job-Anzahl
|
||||
|
||||
## 🔒 Sicherheitsaspekte
|
||||
|
||||
### CSRF-Schutz:
|
||||
- Alle POST-Requests verwenden CSRF-Token
|
||||
- Token-Validierung im Backend
|
||||
|
||||
### Input-Validierung:
|
||||
```python
|
||||
def validate_optimization_settings(settings):
|
||||
valid_algorithms = ['round_robin', 'load_balance', 'priority_based']
|
||||
if settings.get('algorithm') not in valid_algorithms:
|
||||
return False
|
||||
# Weitere Validierungen...
|
||||
```
|
||||
|
||||
### Permission-Checks:
|
||||
- Nur authentifizierte Benutzer können optimieren
|
||||
- Admin-Level-Funktionen separat geschützt
|
||||
|
||||
## 📈 Monitoring und Analytics
|
||||
|
||||
### Logging:
|
||||
```python
|
||||
jobs_logger.info(f"Auto-Optimierung durchgeführt: {optimized_count} Jobs optimiert mit Algorithmus {algorithm}")
|
||||
```
|
||||
|
||||
### Metriken:
|
||||
- Anzahl durchgeführter Optimierungen
|
||||
- Durchschnittliche Optimierungs-Dauer
|
||||
- Benutzer-Engagement mit Modal-Features
|
||||
|
||||
## 🚨 Fehlerbehebung
|
||||
|
||||
### Häufige Probleme:
|
||||
|
||||
#### Modal erscheint nicht:
|
||||
1. Browser-Konsole auf JavaScript-Fehler prüfen
|
||||
2. CSS-Datei korrekt eingebunden?
|
||||
3. CSRF-Token verfügbar?
|
||||
|
||||
#### Animationen ruckeln:
|
||||
1. GPU-Beschleunigung aktiviert?
|
||||
2. Zu viele parallele Animationen?
|
||||
3. Performance-optimierte CSS-Properties verwendet?
|
||||
|
||||
#### Audio funktioniert nicht:
|
||||
1. Browser unterstützt Web Audio API?
|
||||
2. Benutzer-Interaktion vor Audio-Aufruf?
|
||||
3. Audiokontext erstellt?
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
### Version 1.0.0 (Aktuell)
|
||||
- ✅ Auto-Optimierung-Endpunkt implementiert
|
||||
- ✅ Belohnendes Modal mit Animationen
|
||||
- ✅ Drei Optimierungs-Algorithmen
|
||||
- ✅ Audio-Feedback
|
||||
- ✅ Responsive Design
|
||||
- ✅ Performance-Optimierungen
|
||||
- ✅ Umfassende Dokumentation
|
||||
|
||||
### Geplante Verbesserungen:
|
||||
- [ ] Achievement-System
|
||||
- [ ] Benutzer-spezifische Animation-Einstellungen
|
||||
- [ ] Erweiterte Analytics
|
||||
- [ ] A/B-Testing für Modal-Varianten
|
||||
|
||||
## 🤝 Beitragen
|
||||
|
||||
### Code-Standards:
|
||||
- Deutsche Kommentare und Dokumentation
|
||||
- Produktions-bereit implementieren
|
||||
- Cascade-Analyse bei Änderungen
|
||||
- Vollständige Fehlerbehandlung
|
||||
|
||||
### Pull-Request-Checklist:
|
||||
- [ ] Funktionalität getestet
|
||||
- [ ] Dokumentation aktualisiert
|
||||
- [ ] Deutsche Kommentare hinzugefügt
|
||||
- [ ] Performance-Impact evaluiert
|
||||
- [ ] Mobile Responsivität geprüft
|
@ -2508,3 +2508,9 @@
|
||||
2025-05-31 23:29:42 - myp.printers - INFO - ✅ Live-Status-Abfrage erfolgreich: 0 Drucker
|
||||
2025-05-31 23:31:14 - myp.printers - INFO - 🔄 Live-Status-Abfrage von Benutzer Administrator (ID: 1)
|
||||
2025-05-31 23:31:14 - myp.printers - INFO - ✅ Live-Status-Abfrage erfolgreich: 0 Drucker
|
||||
2025-05-31 23:34:40 - myp.printers - INFO - Schnelles Laden abgeschlossen: 6 Drucker geladen (ohne Status-Check)
|
||||
2025-05-31 23:34:44 - myp.printers - INFO - Schnelles Laden abgeschlossen: 6 Drucker geladen (ohne Status-Check)
|
||||
2025-05-31 23:35:03 - myp.printers - INFO - 🔄 Live-Status-Abfrage von Benutzer Administrator (ID: 1)
|
||||
2025-05-31 23:35:03 - myp.printers - INFO - ✅ Live-Status-Abfrage erfolgreich: 0 Drucker
|
||||
2025-05-31 23:35:04 - myp.printers - INFO - Schnelles Laden abgeschlossen: 6 Drucker geladen (ohne Status-Check)
|
||||
2025-05-31 23:35:14 - myp.printers - INFO - Schnelles Laden abgeschlossen: 6 Drucker geladen (ohne Status-Check)
|
||||
|
@ -2683,3 +2683,6 @@
|
||||
2025-05-31 23:32:11 - myp.scheduler - INFO - Scheduler gestartet
|
||||
2025-05-31 23:32:16 - myp.scheduler - INFO - Scheduler-Thread beendet
|
||||
2025-05-31 23:32:16 - myp.scheduler - INFO - Scheduler gestoppt
|
||||
2025-05-31 23:34:21 - myp.scheduler - INFO - Task check_jobs registriert: Intervall 30s, Enabled: True
|
||||
2025-05-31 23:34:22 - myp.scheduler - INFO - Scheduler-Thread gestartet
|
||||
2025-05-31 23:34:22 - myp.scheduler - INFO - Scheduler gestartet
|
||||
|
341
backend/static/css/optimization-animations.css
Normal file
341
backend/static/css/optimization-animations.css
Normal file
@ -0,0 +1,341 @@
|
||||
/**
|
||||
* MYP Platform - Optimierungs-Animationen
|
||||
* Belohnende und motivierende Animationen für Auto-Optimierung
|
||||
*/
|
||||
|
||||
/* ===== MODAL FADE-IN ANIMATION ===== */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ===== BOUNCE-IN ANIMATION ===== */
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3) translateY(-50px);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05) translateY(0);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
/* ===== PULSE-SCALE ANIMATION ===== */
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-scale {
|
||||
animation: pulse-scale 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== FLOATING ANIMATIONS ===== */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-15px) rotate(5deg);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-5px) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-delay {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-10px) rotate(-5deg);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-8px) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animate-float-delay {
|
||||
animation: float-delay 3s infinite ease-in-out;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
/* ===== SLIDE-UP ANIMATIONS ===== */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up-delay {
|
||||
animation: slide-up 0.6s ease-out;
|
||||
animation-delay: 0.2s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.animate-slide-up-delay-2 {
|
||||
animation: slide-up 0.6s ease-out;
|
||||
animation-delay: 0.4s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.animate-slide-up-delay-3 {
|
||||
animation: slide-up 0.6s ease-out;
|
||||
animation-delay: 0.6s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* ===== COUNT-UP ANIMATION ===== */
|
||||
@keyframes count-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(-10deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2) rotate(5deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-count-up {
|
||||
animation: count-up 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
animation-delay: 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* ===== GLOW ANIMATION ===== */
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(59, 130, 246, 0.5),
|
||||
0 0 10px rgba(59, 130, 246, 0.3),
|
||||
0 0 15px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8),
|
||||
0 0 30px rgba(59, 130, 246, 0.6),
|
||||
0 0 40px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== KONFETTI ANIMATION ===== */
|
||||
.confetti-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confetti-piece {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
top: -10px;
|
||||
border-radius: 2px;
|
||||
animation: confetti-fall linear infinite;
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(-100vh) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ERFOLGS-BADGE SPEZIAL-ANIMATIONEN ===== */
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.badge-star {
|
||||
animation: star-twinkle 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== LOADING SPINNER IMPROVEMENTS ===== */
|
||||
@keyframes spinner-glow {
|
||||
0% {
|
||||
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.5));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 15px rgba(59, 130, 246, 0.8));
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
#optimization-loader .animate-spin {
|
||||
animation: spin 1s linear infinite, spinner-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE OPTIMIERUNGEN ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.animate-glow {
|
||||
animation: glow-dark 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes glow-dark {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(96, 165, 250, 0.5),
|
||||
0 0 10px rgba(96, 165, 250, 0.3),
|
||||
0 0 15px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.8),
|
||||
0 0 30px rgba(96, 165, 250, 0.6),
|
||||
0 0 40px rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== PERFORMANCE OPTIMIERUNGEN ===== */
|
||||
.animate-bounce-in,
|
||||
.animate-fade-in,
|
||||
.animate-slide-up,
|
||||
.animate-slide-up-delay,
|
||||
.animate-slide-up-delay-2,
|
||||
.animate-slide-up-delay-3 {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.animate-pulse-scale,
|
||||
.animate-float,
|
||||
.animate-float-delay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
will-change: box-shadow;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ANPASSUNGEN ===== */
|
||||
@media (max-width: 768px) {
|
||||
.confetti-piece {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
#optimization-reward-modal .text-8xl {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
#optimization-reward-modal .max-w-md {
|
||||
max-width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BUTTON HOVER VERBESSERUNGEN ===== */
|
||||
button:hover .badge-star {
|
||||
animation-duration: 0.8s;
|
||||
}
|
||||
|
||||
/* ===== ZUSÄTZLICHE UTILITY CLASSES ===== */
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.animate-heartbeat {
|
||||
animation: heartbeat 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes heartbeat {
|
||||
0%, 100% { transform: scale(1); }
|
||||
14% { transform: scale(1.1); }
|
||||
28% { transform: scale(1); }
|
||||
42% { transform: scale(1.1); }
|
||||
70% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ===== PROGRESS BAR ANIMATION ===== */
|
||||
.progress-fill {
|
||||
animation: progress-grow 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes progress-grow {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: var(--progress-width, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== SUCCESS CHECKMARK ANIMATION ===== */
|
||||
.success-checkmark {
|
||||
animation: checkmark-draw 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes checkmark-draw {
|
||||
0% {
|
||||
stroke-dasharray: 0 100;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 100 0;
|
||||
}
|
||||
}
|
@ -210,6 +210,9 @@ class OptimizationManager {
|
||||
*/
|
||||
async performAutoOptimization() {
|
||||
try {
|
||||
// Ladeanimation anzeigen
|
||||
this.showOptimizationLoading();
|
||||
|
||||
const response = await fetch('/api/optimization/auto-optimize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -224,18 +227,249 @@ class OptimizationManager {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Ladeanimation ausblenden
|
||||
this.hideOptimizationLoading();
|
||||
|
||||
if (data.success) {
|
||||
this.showSuccessMessage(`Optimierung erfolgreich: ${data.optimized_jobs} Jobs optimiert`);
|
||||
// Belohnendes animiertes Modal anzeigen
|
||||
this.showRewardModal(data);
|
||||
this.refreshCurrentView();
|
||||
} else {
|
||||
this.showErrorMessage(`Optimierung fehlgeschlagen: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.hideOptimizationLoading();
|
||||
console.error('Auto-Optimierung Fehler:', error);
|
||||
this.showErrorMessage('Netzwerkfehler bei der Auto-Optimierung');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Belohnendes animiertes Modal für erfolgreiche Optimierung
|
||||
*/
|
||||
showRewardModal(data) {
|
||||
// Existing Modal entfernen falls vorhanden
|
||||
const existingModal = document.getElementById('optimization-reward-modal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'optimization-reward-modal';
|
||||
modal.className = 'fixed inset-0 bg-black/70 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-fade-in';
|
||||
|
||||
// Erfolgs-Emojis basierend auf Anzahl der optimierten Jobs
|
||||
const optimizedCount = data.optimized_jobs || 0;
|
||||
let celebration = '🎉';
|
||||
let message = 'Optimierung erfolgreich!';
|
||||
|
||||
if (optimizedCount === 0) {
|
||||
celebration = '✅';
|
||||
message = 'System bereits optimal!';
|
||||
} else if (optimizedCount <= 3) {
|
||||
celebration = '🚀';
|
||||
message = 'Kleine Verbesserungen durchgeführt!';
|
||||
} else if (optimizedCount <= 10) {
|
||||
celebration = '⚡';
|
||||
message = 'Deutliche Optimierung erreicht!';
|
||||
} else {
|
||||
celebration = '💎';
|
||||
message = 'Exzellente Optimierung!';
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-8 transform animate-bounce-in">
|
||||
<!-- Konfetti Animation -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl">
|
||||
<div class="confetti-container">
|
||||
${this.generateConfetti()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schließen Button -->
|
||||
<button onclick="this.closest('#optimization-reward-modal').remove()"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors z-10">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Haupt-Content -->
|
||||
<div class="text-center relative z-10">
|
||||
<!-- Animiertes Erfolgs-Icon -->
|
||||
<div class="relative mb-6">
|
||||
<div class="text-8xl animate-pulse-scale mx-auto">${celebration}</div>
|
||||
<div class="absolute -top-2 -right-2 text-4xl animate-float">✨</div>
|
||||
<div class="absolute -bottom-2 -left-2 text-3xl animate-float-delay">⭐</div>
|
||||
</div>
|
||||
|
||||
<!-- Erfolgs-Nachricht -->
|
||||
<h2 class="text-2xl font-bold text-slate-800 dark:text-white mb-2 animate-slide-up">
|
||||
${message}
|
||||
</h2>
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20
|
||||
rounded-xl p-6 mb-6 animate-slide-up-delay">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600 dark:text-green-400 animate-count-up">
|
||||
${optimizedCount}
|
||||
</div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 font-medium">
|
||||
Jobs optimiert
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-slate-700 dark:text-slate-300 capitalize">
|
||||
${data.algorithm?.replace('_', ' ') || 'Standard'}
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Algorithmus
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Belohnungs-Badge -->
|
||||
<div class="inline-flex items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600
|
||||
text-white px-6 py-3 rounded-full text-sm font-semibold mb-6 animate-glow">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
Effizienz-Boost erreicht!
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button onclick="this.closest('#optimization-reward-modal').remove()"
|
||||
class="flex-1 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300
|
||||
py-3 px-6 rounded-xl font-semibold transition-all hover:bg-slate-200
|
||||
dark:hover:bg-slate-600 animate-slide-up-delay-2">
|
||||
Schließen
|
||||
</button>
|
||||
<button onclick="optimizationManager.showOptimizationSettings();
|
||||
this.closest('#optimization-reward-modal').remove();"
|
||||
class="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white
|
||||
py-3 px-6 rounded-xl font-semibold transition-all hover:from-blue-600
|
||||
hover:to-blue-700 animate-slide-up-delay-3">
|
||||
Einstellungen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Modal zum DOM hinzufügen
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Sound-Effekt (optional)
|
||||
this.playSuccessSound();
|
||||
|
||||
// Auto-Close nach 10 Sekunden
|
||||
setTimeout(() => {
|
||||
if (modal && modal.parentNode) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => modal.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfetti-Animation generieren
|
||||
*/
|
||||
generateConfetti() {
|
||||
const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
|
||||
let confetti = '';
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const delay = Math.random() * 3;
|
||||
const duration = 3 + Math.random() * 2;
|
||||
const left = Math.random() * 100;
|
||||
|
||||
confetti += `
|
||||
<div class="confetti-piece" style="
|
||||
background-color: ${color};
|
||||
left: ${left}%;
|
||||
animation-delay: ${delay}s;
|
||||
animation-duration: ${duration}s;
|
||||
"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
return confetti;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladeanimation für Optimierung anzeigen
|
||||
*/
|
||||
showOptimizationLoading() {
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'optimization-loader';
|
||||
loader.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-40 flex items-center justify-center';
|
||||
loader.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl p-8 text-center max-w-sm mx-4 shadow-2xl">
|
||||
<div class="relative mb-6">
|
||||
<div class="w-16 h-16 mx-auto">
|
||||
<svg class="animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute -top-1 -right-1 text-2xl animate-bounce">⚡</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-slate-800 dark:text-white mb-2">
|
||||
Optimierung läuft...
|
||||
</h3>
|
||||
<p class="text-slate-600 dark:text-slate-400">
|
||||
Jobs werden intelligent verteilt
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladeanimation ausblenden
|
||||
*/
|
||||
hideOptimizationLoading() {
|
||||
const loader = document.getElementById('optimization-loader');
|
||||
if (loader) {
|
||||
loader.style.opacity = '0';
|
||||
setTimeout(() => loader.remove(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erfolgs-Sound abspielen (optional)
|
||||
*/
|
||||
playSuccessSound() {
|
||||
try {
|
||||
// Erstelle einen kurzen, angenehmen Ton
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(523.25, audioContext.currentTime); // C5
|
||||
oscillator.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1); // E5
|
||||
oscillator.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2); // G5
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.5);
|
||||
} catch (e) {
|
||||
// Sound nicht verfügbar, ignorieren
|
||||
console.log('Audio-API nicht verfügbar');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-Operationen durchführen
|
||||
*/
|
||||
|
@ -29,6 +29,7 @@
|
||||
<link href="{{ url_for('static', filename='css/tailwind.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/components.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/professional-theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/optimization-animations.css') }}" rel="stylesheet">
|
||||
|
||||
<!-- Preload critical resources -->
|
||||
<link rel="preload" href="{{ url_for('static', filename='js/ui-components.js') }}" as="script">
|
||||
|
663
backend/utils/form_validation.py
Normal file
663
backend/utils/form_validation.py
Normal file
@ -0,0 +1,663 @@
|
||||
"""
|
||||
Erweiterte Formular-Validierung für das MYP-System
|
||||
==================================================
|
||||
|
||||
Dieses Modul stellt umfassende Client- und serverseitige Validierung
|
||||
mit benutzerfreundlichem UI-Feedback bereit.
|
||||
|
||||
Funktionen:
|
||||
- Multi-Level-Validierung (Client/Server)
|
||||
- Echtzeitvalidierung mit JavaScript
|
||||
- Barrierefreie Fehlermeldungen
|
||||
- Custom Validators für spezielle Anforderungen
|
||||
- Automatische Sanitization von Eingaben
|
||||
"""
|
||||
|
||||
import re
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Callable, Union
|
||||
from datetime import datetime, timedelta
|
||||
from flask import request, jsonify, session
|
||||
from functools import wraps
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import ALLOWED_EXTENSIONS, MAX_FILE_SIZE
|
||||
|
||||
logger = get_logger("validation")
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Custom Exception für Validierungsfehler"""
|
||||
def __init__(self, message: str, field: str = None, code: str = None):
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
class ValidationResult:
|
||||
"""Ergebnis einer Validierung"""
|
||||
def __init__(self):
|
||||
self.is_valid = True
|
||||
self.errors: Dict[str, List[str]] = {}
|
||||
self.warnings: Dict[str, List[str]] = {}
|
||||
self.cleaned_data: Dict[str, Any] = {}
|
||||
|
||||
def add_error(self, field: str, message: str):
|
||||
"""Fügt einen Validierungsfehler hinzu"""
|
||||
if field not in self.errors:
|
||||
self.errors[field] = []
|
||||
self.errors[field].append(message)
|
||||
self.is_valid = False
|
||||
|
||||
def add_warning(self, field: str, message: str):
|
||||
"""Fügt eine Warnung hinzu"""
|
||||
if field not in self.warnings:
|
||||
self.warnings[field] = []
|
||||
self.warnings[field].append(message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Konvertiert das Ergebnis zu einem Dictionary"""
|
||||
return {
|
||||
"is_valid": self.is_valid,
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
"cleaned_data": self.cleaned_data
|
||||
}
|
||||
|
||||
class BaseValidator:
|
||||
"""Basis-Klasse für alle Validatoren"""
|
||||
|
||||
def __init__(self, required: bool = False, allow_empty: bool = True):
|
||||
self.required = required
|
||||
self.allow_empty = allow_empty
|
||||
|
||||
def validate(self, value: Any, field_name: str = None) -> ValidationResult:
|
||||
"""Führt die Validierung durch"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Prüfung auf erforderliche Felder
|
||||
if self.required and (value is None or value == ""):
|
||||
result.add_error(field_name or "field", "Dieses Feld ist erforderlich.")
|
||||
return result
|
||||
|
||||
# Wenn Wert leer und erlaubt, keine weitere Validierung
|
||||
if not value and self.allow_empty:
|
||||
result.cleaned_data[field_name or "field"] = value
|
||||
return result
|
||||
|
||||
return self._validate_value(value, field_name, result)
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
"""Überschreibbar für spezifische Validierungslogik"""
|
||||
result.cleaned_data[field_name or "field"] = value
|
||||
return result
|
||||
|
||||
class StringValidator(BaseValidator):
|
||||
"""Validator für String-Werte"""
|
||||
|
||||
def __init__(self, min_length: int = None, max_length: int = None,
|
||||
pattern: str = None, trim: bool = True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.min_length = min_length
|
||||
self.max_length = max_length
|
||||
self.pattern = re.compile(pattern) if pattern else None
|
||||
self.trim = trim
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
# String konvertieren und trimmen
|
||||
str_value = str(value)
|
||||
if self.trim:
|
||||
str_value = str_value.strip()
|
||||
|
||||
# Längenprüfung
|
||||
if self.min_length is not None and len(str_value) < self.min_length:
|
||||
result.add_error(field_name, f"Mindestlänge: {self.min_length} Zeichen")
|
||||
|
||||
if self.max_length is not None and len(str_value) > self.max_length:
|
||||
result.add_error(field_name, f"Maximallänge: {self.max_length} Zeichen")
|
||||
|
||||
# Pattern-Prüfung
|
||||
if self.pattern and not self.pattern.match(str_value):
|
||||
result.add_error(field_name, "Format ist ungültig")
|
||||
|
||||
# HTML-Sanitization
|
||||
cleaned_value = html.escape(str_value)
|
||||
result.cleaned_data[field_name] = cleaned_value
|
||||
|
||||
return result
|
||||
|
||||
class EmailValidator(StringValidator):
|
||||
"""Validator für E-Mail-Adressen"""
|
||||
|
||||
EMAIL_PATTERN = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(pattern=self.EMAIL_PATTERN, **kwargs)
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
result = super()._validate_value(value, field_name, result)
|
||||
|
||||
if result.is_valid:
|
||||
# Normalisierung der E-Mail
|
||||
email = str(value).lower().strip()
|
||||
result.cleaned_data[field_name] = email
|
||||
|
||||
return result
|
||||
|
||||
class IntegerValidator(BaseValidator):
|
||||
"""Validator für Integer-Werte"""
|
||||
|
||||
def __init__(self, min_value: int = None, max_value: int = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
try:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
result.add_error(field_name, "Muss eine ganze Zahl sein")
|
||||
return result
|
||||
|
||||
if self.min_value is not None and int_value < self.min_value:
|
||||
result.add_error(field_name, f"Mindestwert: {self.min_value}")
|
||||
|
||||
if self.max_value is not None and int_value > self.max_value:
|
||||
result.add_error(field_name, f"Maximalwert: {self.max_value}")
|
||||
|
||||
result.cleaned_data[field_name] = int_value
|
||||
return result
|
||||
|
||||
class FloatValidator(BaseValidator):
|
||||
"""Validator für Float-Werte"""
|
||||
|
||||
def __init__(self, min_value: float = None, max_value: float = None,
|
||||
decimal_places: int = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.decimal_places = decimal_places
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
try:
|
||||
float_value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
result.add_error(field_name, "Muss eine Dezimalzahl sein")
|
||||
return result
|
||||
|
||||
if self.min_value is not None and float_value < self.min_value:
|
||||
result.add_error(field_name, f"Mindestwert: {self.min_value}")
|
||||
|
||||
if self.max_value is not None and float_value > self.max_value:
|
||||
result.add_error(field_name, f"Maximalwert: {self.max_value}")
|
||||
|
||||
# Rundung auf bestimmte Dezimalstellen
|
||||
if self.decimal_places is not None:
|
||||
float_value = round(float_value, self.decimal_places)
|
||||
|
||||
result.cleaned_data[field_name] = float_value
|
||||
return result
|
||||
|
||||
class DateTimeValidator(BaseValidator):
|
||||
"""Validator für DateTime-Werte"""
|
||||
|
||||
def __init__(self, format_string: str = "%Y-%m-%d %H:%M",
|
||||
min_date: datetime = None, max_date: datetime = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.format_string = format_string
|
||||
self.min_date = min_date
|
||||
self.max_date = max_date
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
if isinstance(value, datetime):
|
||||
dt_value = value
|
||||
else:
|
||||
try:
|
||||
dt_value = datetime.strptime(str(value), self.format_string)
|
||||
except ValueError:
|
||||
result.add_error(field_name, f"Ungültiges Datumsformat. Erwartet: {self.format_string}")
|
||||
return result
|
||||
|
||||
if self.min_date and dt_value < self.min_date:
|
||||
result.add_error(field_name, f"Datum muss nach {self.min_date.strftime('%d.%m.%Y')} liegen")
|
||||
|
||||
if self.max_date and dt_value > self.max_date:
|
||||
result.add_error(field_name, f"Datum muss vor {self.max_date.strftime('%d.%m.%Y')} liegen")
|
||||
|
||||
result.cleaned_data[field_name] = dt_value
|
||||
return result
|
||||
|
||||
class FileValidator(BaseValidator):
|
||||
"""Validator für Datei-Uploads"""
|
||||
|
||||
def __init__(self, allowed_extensions: List[str] = None,
|
||||
max_size_mb: int = None, min_size_kb: int = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.allowed_extensions = allowed_extensions or ALLOWED_EXTENSIONS
|
||||
self.max_size_mb = max_size_mb or (MAX_FILE_SIZE / (1024 * 1024))
|
||||
self.min_size_kb = min_size_kb
|
||||
|
||||
def _validate_value(self, value: Any, field_name: str, result: ValidationResult) -> ValidationResult:
|
||||
if not isinstance(value, FileStorage):
|
||||
result.add_error(field_name, "Muss eine gültige Datei sein")
|
||||
return result
|
||||
|
||||
# Dateiname prüfen
|
||||
if not value.filename:
|
||||
result.add_error(field_name, "Dateiname ist erforderlich")
|
||||
return result
|
||||
|
||||
# Dateierweiterung prüfen
|
||||
extension = value.filename.rsplit('.', 1)[-1].lower() if '.' in value.filename else ''
|
||||
if extension not in self.allowed_extensions:
|
||||
result.add_error(field_name,
|
||||
f"Nur folgende Dateiformate sind erlaubt: {', '.join(self.allowed_extensions)}")
|
||||
|
||||
# Dateigröße prüfen
|
||||
value.seek(0, 2) # Zum Ende der Datei
|
||||
file_size = value.tell()
|
||||
value.seek(0) # Zurück zum Anfang
|
||||
|
||||
if self.max_size_mb and file_size > (self.max_size_mb * 1024 * 1024):
|
||||
result.add_error(field_name, f"Datei zu groß. Maximum: {self.max_size_mb} MB")
|
||||
|
||||
if self.min_size_kb and file_size < (self.min_size_kb * 1024):
|
||||
result.add_error(field_name, f"Datei zu klein. Minimum: {self.min_size_kb} KB")
|
||||
|
||||
result.cleaned_data[field_name] = value
|
||||
return result
|
||||
|
||||
class FormValidator:
|
||||
"""Haupt-Formular-Validator"""
|
||||
|
||||
def __init__(self):
|
||||
self.fields: Dict[str, BaseValidator] = {}
|
||||
self.custom_validators: List[Callable] = []
|
||||
self.rate_limit_key = None
|
||||
self.csrf_check = True
|
||||
|
||||
def add_field(self, name: str, validator: BaseValidator):
|
||||
"""Fügt ein Feld mit Validator hinzu"""
|
||||
self.fields[name] = validator
|
||||
return self
|
||||
|
||||
def add_custom_validator(self, validator_func: Callable):
|
||||
"""Fügt einen benutzerdefinierten Validator hinzu"""
|
||||
self.custom_validators.append(validator_func)
|
||||
return self
|
||||
|
||||
def set_rate_limit(self, key: str):
|
||||
"""Setzt einen Rate-Limiting-Schlüssel"""
|
||||
self.rate_limit_key = key
|
||||
return self
|
||||
|
||||
def disable_csrf(self):
|
||||
"""Deaktiviert CSRF-Prüfung für dieses Formular"""
|
||||
self.csrf_check = False
|
||||
return self
|
||||
|
||||
def validate(self, data: Dict[str, Any]) -> ValidationResult:
|
||||
"""Validiert die gesamten Formulardaten"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Einzelfeldvalidierung
|
||||
for field_name, validator in self.fields.items():
|
||||
field_value = data.get(field_name)
|
||||
field_result = validator.validate(field_value, field_name)
|
||||
|
||||
if not field_result.is_valid:
|
||||
result.errors.update(field_result.errors)
|
||||
result.is_valid = False
|
||||
|
||||
result.warnings.update(field_result.warnings)
|
||||
result.cleaned_data.update(field_result.cleaned_data)
|
||||
|
||||
# Benutzerdefinierte Validierung
|
||||
if result.is_valid:
|
||||
for custom_validator in self.custom_validators:
|
||||
try:
|
||||
custom_result = custom_validator(result.cleaned_data)
|
||||
if isinstance(custom_result, ValidationResult):
|
||||
if not custom_result.is_valid:
|
||||
result.errors.update(custom_result.errors)
|
||||
result.is_valid = False
|
||||
result.warnings.update(custom_result.warnings)
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei benutzerdefinierter Validierung: {str(e)}")
|
||||
result.add_error("form", "Unerwarteter Validierungsfehler")
|
||||
|
||||
return result
|
||||
|
||||
# Vordefinierte Formular-Validatoren
|
||||
def get_user_registration_validator() -> FormValidator:
|
||||
"""Validator für Benutzerregistrierung"""
|
||||
return FormValidator() \
|
||||
.add_field("username", StringValidator(min_length=3, max_length=50, required=True)) \
|
||||
.add_field("email", EmailValidator(required=True)) \
|
||||
.add_field("password", StringValidator(min_length=8, required=True)) \
|
||||
.add_field("password_confirm", StringValidator(min_length=8, required=True)) \
|
||||
.add_field("name", StringValidator(min_length=2, max_length=100, required=True)) \
|
||||
.add_custom_validator(lambda data: _validate_password_match(data))
|
||||
|
||||
def get_job_creation_validator() -> FormValidator:
|
||||
"""Validator für Job-Erstellung"""
|
||||
return FormValidator() \
|
||||
.add_field("name", StringValidator(min_length=1, max_length=200, required=True)) \
|
||||
.add_field("description", StringValidator(max_length=500)) \
|
||||
.add_field("printer_id", IntegerValidator(min_value=1, required=True)) \
|
||||
.add_field("duration_minutes", IntegerValidator(min_value=1, max_value=1440, required=True)) \
|
||||
.add_field("start_at", DateTimeValidator(min_date=datetime.now())) \
|
||||
.add_field("file", FileValidator(required=True))
|
||||
|
||||
def get_printer_creation_validator() -> FormValidator:
|
||||
"""Validator für Drucker-Erstellung"""
|
||||
return FormValidator() \
|
||||
.add_field("name", StringValidator(min_length=1, max_length=100, required=True)) \
|
||||
.add_field("model", StringValidator(max_length=100)) \
|
||||
.add_field("location", StringValidator(max_length=100)) \
|
||||
.add_field("ip_address", StringValidator(pattern=r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')) \
|
||||
.add_field("mac_address", StringValidator(pattern=r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', required=True)) \
|
||||
.add_field("plug_ip", StringValidator(pattern=r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', required=True)) \
|
||||
.add_field("plug_username", StringValidator(min_length=1, required=True)) \
|
||||
.add_field("plug_password", StringValidator(min_length=1, required=True))
|
||||
|
||||
def get_guest_request_validator() -> FormValidator:
|
||||
"""Validator für Gastanfragen"""
|
||||
return FormValidator() \
|
||||
.add_field("name", StringValidator(min_length=2, max_length=100, required=True)) \
|
||||
.add_field("email", EmailValidator()) \
|
||||
.add_field("reason", StringValidator(min_length=10, max_length=500, required=True)) \
|
||||
.add_field("duration_minutes", IntegerValidator(min_value=5, max_value=480, required=True)) \
|
||||
.add_field("copies", IntegerValidator(min_value=1, max_value=10)) \
|
||||
.add_field("file", FileValidator(required=True)) \
|
||||
.set_rate_limit("guest_request")
|
||||
|
||||
def _validate_password_match(data: Dict[str, Any]) -> ValidationResult:
|
||||
"""Validiert, ob Passwörter übereinstimmen"""
|
||||
result = ValidationResult()
|
||||
|
||||
password = data.get("password")
|
||||
password_confirm = data.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
result.add_error("password_confirm", "Passwörter stimmen nicht überein")
|
||||
|
||||
return result
|
||||
|
||||
# Decorator für automatische Formularvalidierung
|
||||
def validate_form(validator_func: Callable[[], FormValidator]):
|
||||
"""Decorator für automatische Formularvalidierung"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
try:
|
||||
# Validator erstellen
|
||||
validator = validator_func()
|
||||
|
||||
# Daten aus Request extrahieren
|
||||
if request.is_json:
|
||||
data = request.get_json() or {}
|
||||
else:
|
||||
data = dict(request.form)
|
||||
# Dateien hinzufügen
|
||||
for key, file in request.files.items():
|
||||
data[key] = file
|
||||
|
||||
# Validierung durchführen
|
||||
validation_result = validator.validate(data)
|
||||
|
||||
# Bei Fehlern JSON-Response zurückgeben
|
||||
if not validation_result.is_valid:
|
||||
logger.warning(f"Validierungsfehler für {request.endpoint}: {validation_result.errors}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"errors": validation_result.errors,
|
||||
"warnings": validation_result.warnings
|
||||
}), 400
|
||||
|
||||
# Gereinigte Daten an die Request anhängen
|
||||
request.validated_data = validation_result.cleaned_data
|
||||
request.validation_warnings = validation_result.warnings
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Formularvalidierung: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"errors": {"form": ["Unerwarteter Validierungsfehler"]}
|
||||
}), 500
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
# JavaScript für Client-seitige Validierung
|
||||
def get_client_validation_js() -> str:
|
||||
"""Generiert JavaScript für Client-seitige Validierung"""
|
||||
return """
|
||||
class FormValidator {
|
||||
constructor(formId, validationRules = {}) {
|
||||
this.form = document.getElementById(formId);
|
||||
this.rules = validationRules;
|
||||
this.errors = {};
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
if (!this.form) return;
|
||||
|
||||
// Echtzeit-Validierung bei Eingabe
|
||||
this.form.addEventListener('input', (e) => {
|
||||
this.validateField(e.target);
|
||||
});
|
||||
|
||||
// Formular-Submission
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
if (!this.validateForm()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateField(field) {
|
||||
const fieldName = field.name;
|
||||
const value = field.value;
|
||||
const rule = this.rules[fieldName];
|
||||
|
||||
if (!rule) return true;
|
||||
|
||||
this.clearFieldError(field);
|
||||
|
||||
// Required-Prüfung
|
||||
if (rule.required && (!value || value.trim() === '')) {
|
||||
this.addFieldError(field, 'Dieses Feld ist erforderlich.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Längenprüfung
|
||||
if (rule.minLength && value.length < rule.minLength) {
|
||||
this.addFieldError(field, `Mindestlänge: ${rule.minLength} Zeichen`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rule.maxLength && value.length > rule.maxLength) {
|
||||
this.addFieldError(field, `Maximallänge: ${rule.maxLength} Zeichen`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pattern-Prüfung
|
||||
if (rule.pattern && !new RegExp(rule.pattern).test(value)) {
|
||||
this.addFieldError(field, rule.patternMessage || 'Format ist ungültig');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Email-Prüfung
|
||||
if (rule.type === 'email' && value) {
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
if (!emailPattern.test(value)) {
|
||||
this.addFieldError(field, 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Validierung
|
||||
if (rule.customValidator) {
|
||||
const customResult = rule.customValidator(value, field);
|
||||
if (customResult !== true) {
|
||||
this.addFieldError(field, customResult);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
let isValid = true;
|
||||
this.errors = {};
|
||||
|
||||
// Alle Felder validieren
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
fields.forEach(field => {
|
||||
if (!this.validateField(field)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Custom Form-Validierung
|
||||
if (this.rules._formValidator) {
|
||||
const formData = new FormData(this.form);
|
||||
const customResult = this.rules._formValidator(formData, this.form);
|
||||
if (customResult !== true) {
|
||||
this.addFormError(customResult);
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
addFieldError(field, message) {
|
||||
const fieldName = field.name;
|
||||
|
||||
// Error-Container finden oder erstellen
|
||||
let errorContainer = field.parentNode.querySelector('.field-error');
|
||||
if (!errorContainer) {
|
||||
errorContainer = document.createElement('div');
|
||||
errorContainer.className = 'field-error text-red-600 text-sm mt-1';
|
||||
errorContainer.setAttribute('role', 'alert');
|
||||
errorContainer.setAttribute('aria-live', 'polite');
|
||||
field.parentNode.appendChild(errorContainer);
|
||||
}
|
||||
|
||||
errorContainer.textContent = message;
|
||||
field.classList.add('border-red-500');
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
|
||||
// Für Screen Reader
|
||||
if (!field.getAttribute('aria-describedby')) {
|
||||
const errorId = `error-${fieldName}-${Date.now()}`;
|
||||
errorContainer.id = errorId;
|
||||
field.setAttribute('aria-describedby', errorId);
|
||||
}
|
||||
|
||||
this.errors[fieldName] = message;
|
||||
}
|
||||
|
||||
clearFieldError(field) {
|
||||
const errorContainer = field.parentNode.querySelector('.field-error');
|
||||
if (errorContainer) {
|
||||
errorContainer.remove();
|
||||
}
|
||||
|
||||
field.classList.remove('border-red-500');
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.removeAttribute('aria-describedby');
|
||||
|
||||
delete this.errors[field.name];
|
||||
}
|
||||
|
||||
addFormError(message) {
|
||||
let formErrorContainer = this.form.querySelector('.form-error');
|
||||
if (!formErrorContainer) {
|
||||
formErrorContainer = document.createElement('div');
|
||||
formErrorContainer.className = 'form-error bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4';
|
||||
formErrorContainer.setAttribute('role', 'alert');
|
||||
this.form.insertBefore(formErrorContainer, this.form.firstChild);
|
||||
}
|
||||
|
||||
formErrorContainer.textContent = message;
|
||||
}
|
||||
|
||||
clearFormErrors() {
|
||||
const formErrorContainer = this.form.querySelector('.form-error');
|
||||
if (formErrorContainer) {
|
||||
formErrorContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
showServerErrors(errors) {
|
||||
// Server-Fehler anzeigen
|
||||
for (const [fieldName, messages] of Object.entries(errors)) {
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
if (field && messages.length > 0) {
|
||||
this.addFieldError(field, messages[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility-Funktionen
|
||||
window.FormValidationUtils = {
|
||||
// Passwort-Stärke prüfen
|
||||
validatePasswordStrength: (password) => {
|
||||
if (password.length < 8) return 'Passwort muss mindestens 8 Zeichen lang sein';
|
||||
if (!/[A-Z]/.test(password)) return 'Passwort muss mindestens einen Großbuchstaben enthalten';
|
||||
if (!/[a-z]/.test(password)) return 'Passwort muss mindestens einen Kleinbuchstaben enthalten';
|
||||
if (!/[0-9]/.test(password)) return 'Passwort muss mindestens eine Zahl enthalten';
|
||||
return true;
|
||||
},
|
||||
|
||||
// Passwort-Bestätigung prüfen
|
||||
validatePasswordConfirm: (password, confirm) => {
|
||||
return password === confirm ? true : 'Passwörter stimmen nicht überein';
|
||||
},
|
||||
|
||||
// Datei-Validierung
|
||||
validateFile: (file, allowedTypes = [], maxSizeMB = 10) => {
|
||||
if (!file) return 'Bitte wählen Sie eine Datei aus';
|
||||
|
||||
const fileType = file.name.split('.').pop().toLowerCase();
|
||||
if (allowedTypes.length > 0 && !allowedTypes.includes(fileType)) {
|
||||
return `Nur folgende Dateiformate sind erlaubt: ${allowedTypes.join(', ')}`;
|
||||
}
|
||||
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
return `Datei ist zu groß. Maximum: ${maxSizeMB} MB`;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
"""
|
||||
|
||||
def render_validation_errors(errors: Dict[str, List[str]]) -> str:
|
||||
"""Rendert Validierungsfehler als HTML"""
|
||||
if not errors:
|
||||
return ""
|
||||
|
||||
html_parts = ['<div class="validation-errors">']
|
||||
|
||||
for field, messages in errors.items():
|
||||
for message in messages:
|
||||
html_parts.append(
|
||||
f'<div class="error-message bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-2" role="alert">'
|
||||
f'<strong>{field}:</strong> {html.escape(message)}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
html_parts.append('</div>')
|
||||
|
||||
return '\n'.join(html_parts)
|
Loading…
x
Reference in New Issue
Block a user