From 831caec87a46c48db8a446072832092c1d3e06e0 Mon Sep 17 00:00:00 2001 From: Till Tomczak Date: Sat, 31 May 2025 23:44:20 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20"Refactor=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.py | 310 +++++++- backend/database/myp.db | Bin 110592 -> 110592 bytes backend/database/myp.db-shm | Bin 0 -> 32768 bytes backend/database/myp.db-wal | Bin 0 -> 4152 bytes .../AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md | 290 ++++++++ backend/logs/printers/printers.log | 6 + backend/logs/scheduler/scheduler.log | 3 + .../static/css/optimization-animations.css | 341 +++++++++ backend/static/js/optimization-features.js | 236 ++++++- backend/templates/base.html | 1 + backend/utils/form_validation.py | 663 ++++++++++++++++++ 11 files changed, 1828 insertions(+), 22 deletions(-) create mode 100644 backend/database/myp.db-shm create mode 100644 backend/database/myp.db-wal create mode 100644 backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md create mode 100644 backend/static/css/optimization-animations.css create mode 100644 backend/utils/form_validation.py diff --git a/backend/app.py b/backend/app.py index 964bca5a..90a89a71 100644 --- a/backend/app.py +++ b/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 ===== @@ -5668,4 +5666,274 @@ if __name__ == "__main__": stop_queue_manager() except: pass - sys.exit(1) \ No newline at end of file + 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 ===== \ No newline at end of file diff --git a/backend/database/myp.db b/backend/database/myp.db index ad3232598a3032301144d300338e9108d259a67b..2a75394625df1cbc523517e5bd9d9bbcd0e09f78 100644 GIT binary patch delta 123 zcmZp8z}E19O(r+4Gm0<%$uL-Z-1uGDAfP}0#+s& diff --git a/backend/database/myp.db-shm b/backend/database/myp.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..ba938876907a31f80f67a0c30d92f33d202c2de0 GIT binary patch literal 32768 zcmeI*p$)=76b9g<7!;{&LKp!-C5B}>Km`M^0-gzQj6kqx6snf4K4yH_Kb`<@>nmleG6eN`E~Z z^I8 literal 0 HcmV?d00001 diff --git a/backend/database/myp.db-wal b/backend/database/myp.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..9e87a8948b66c8eba7fe50f415f96ad3f16e1301 GIT binary patch literal 4152 zcmXr7XKP~6eI&uaAiw|wjH~_@Fd3%Id2=IzVQ=jno5?^iJ|LEci65SL(CChR!$w}9 zAUoe*2L4<8>-hWl3;8|ym4Vz*Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0>dW+RM=U>ZG}sVQ;Uj=3v)6{QsWa#OY$@GQc^48!J_eoh6;u%Y=l%90adB70#%8F zb!Dc&w1c>LiMgrqskw=nIY3nkhSDr7;@S{95$a&la?C8^hUk(oLx7sKm{??;g$s%@ l^GdL}872{LXa;mPBa5uDI7|p+7mOWWT#{H)T5P5Oga9H3av}f# literal 0 HcmV?d00001 diff --git a/backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md b/backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md new file mode 100644 index 00000000..fd908412 --- /dev/null +++ b/backend/docs/AUTO_OPTIMIERUNG_MODAL_VERBESSERUNGEN.md @@ -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 '<', " + +
+
+ ${this.generateConfetti()} +
+
+ + + + + +
+ +
+
${celebration}
+
+
+
+ + +

+ ${message} +

+ + +
+
+
+
+ ${optimizedCount} +
+
+ Jobs optimiert +
+
+
+
+ ${data.algorithm?.replace('_', ' ') || 'Standard'} +
+
+ Algorithmus +
+
+
+
+ + +
+ + + + Effizienz-Boost erreicht! +
+ + +
+ + +
+
+ + `; + + // 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 += ` +
+ `; + } + + 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 = ` +
+
+
+ + + + +
+
+
+

+ Optimierung läuft... +

+

+ Jobs werden intelligent verteilt +

+
+ `; + 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 */ diff --git a/backend/templates/base.html b/backend/templates/base.html index 7e076878..6a2b107a 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -29,6 +29,7 @@ + diff --git a/backend/utils/form_validation.py b/backend/utils/form_validation.py new file mode 100644 index 00000000..c0157c32 --- /dev/null +++ b/backend/utils/form_validation.py @@ -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 = ['
'] + + for field, messages in errors.items(): + for message in messages: + html_parts.append( + f'' + ) + + html_parts.append('
') + + return '\n'.join(html_parts) \ No newline at end of file