diff --git a/backend/app/FEHLER_BEHOBEN.md b/backend/app/FEHLER_BEHOBEN.md index 2da52e5a..e7c58bcf 100644 --- a/backend/app/FEHLER_BEHOBEN.md +++ b/backend/app/FEHLER_BEHOBEN.md @@ -1401,4 +1401,217 @@ Die Scrollbalken in jobs.html waren immer noch zu auffällig. Benutzer wünschte ✅ **Erscheinen nur bei tatsächlichem Hover über scrollbare Container** ✅ **Design bleibt völlig clean und ungestört** -**Status:** Ultra-dezente Scrollbalken final implementiert \ No newline at end of file +**Status:** Ultra-dezente Scrollbalken final implementiert + +## ✅ 30.05.2025 21:30 - Verbessertes automatisches Session-Management implementiert + +### Problem + +Das automatische Abmelden funktionierte nicht zuverlässig: +- Session-Lifetime war zu lang (7 Tage) und unsicher +- Keine automatische Abmeldung bei Inaktivität +- Keine Benutzer-Warnungen vor Session-Ablauf +- Fehlende Heartbeat-Mechanismen zur Session-Verlängerung +- Keine Session-Sicherheitsfeatures (IP-Tracking, etc.) + +### Root-Cause-Analyse + +**Unzureichendes Session-Management:** +- `SESSION_LIFETIME = timedelta(days=7)` war viel zu lang für Sicherheit +- Keine automatische Inaktivitäts-Überwachung implementiert +- Frontend hatte keine Session-Status-Überwachung +- Keine benutzerfreundlichen Warnungen vor Ablauf +- Fehlende API-Endpunkte für Session-Management + +### Implementierte Lösung + +**1. Backend Session-Management-System:** + + ```python +@app.before_request +def check_session_activity(): + """Überprüft Session-Aktivität und meldet Benutzer bei Inaktivität automatisch ab.""" + # Inaktivitäts-Limits basierend auf Benutzerrolle + max_inactive_minutes = 30 # Standard: 30 Minuten + if hasattr(current_user, 'is_admin') and current_user.is_admin: + max_inactive_minutes = 60 # Admins: 60 Minuten + + # Automatische Abmeldung bei Überschreitung + if inactive_duration > max_inactive_duration: + logout_user() + session.clear() + # JSON-Response für AJAX / HTML-Redirect für normale Requests +``` + +**2. Session-Management API-Endpunkte:** +- `POST /api/session/heartbeat` - Hält Session am Leben (alle 5 Min) +- `GET /api/session/status` - Detaillierter Session-Status mit verbleibender Zeit +- `POST /api/session/extend` - Manuelle Session-Verlängerung (max 2h) + +**3. Frontend Session-Manager (`static/js/session-manager.js`):** + +```javascript +class SessionManager { + constructor() { + this.maxInactiveMinutes = 30; // Standard: 30 Minuten + this.heartbeatInterval = 5 * 60 * 1000; // 5 Minuten + this.warningTime = 5 * 60 * 1000; // 5 Minuten vor Ablauf warnen + this.checkInterval = 30 * 1000; // Alle 30 Sekunden prüfen + } + + // Automatisches Monitoring mit Heartbeat-System + // Modal-Warnungen bei bevorstehendem Ablauf + // Graceful Logout bei Session-Ende +} +``` + +**4. Sicherheitsfeatures:** +- **Session-Aktivitäts-Tracking**: Jede Benutzeraktion aktualisiert `last_activity` +- **IP-Adress-Monitoring**: Warnung bei IP-Wechsel während Session +- **User-Agent-Tracking**: Erkennung von Session-Hijacking-Versuchen +- **Role-basierte Timeouts**: Admins 60min, Benutzer 30min +- **Automatic Cleanup**: Session-Daten werden bei Logout vollständig bereinigt + +**5. Benutzerfreundliche Features:** +- **5-Minuten-Warnung**: Modal-Dialog mit Verlängerungs-Option +- **Toast-Notifications**: Elegante Benachrichtigungen über Session-Status +- **Heartbeat-System**: Automatische Session-Verlängerung bei Aktivität +- **Session-Status-Display**: Verbleibende Zeit in der Navigation (optional) +- **Graceful Logout**: Saubere Weiterleitung zur Login-Seite + +**6. Optimierte Konfiguration:** + ```python +SESSION_LIFETIME = timedelta(hours=2) # Reduziert von 7 Tagen auf 2 Stunden +``` + +### Technische Features + +**Backend-Integration:** +- Automatische Session-Checks vor jedem Request +- Session-Sicherheit mit IP/User-Agent-Tracking +- Robuste Error-Handling für alle Session-Operationen +- Detailliertes Logging für Security-Monitoring + +**Frontend-Integration:** +- Automatischer Start nach DOM-Load (nur für angemeldete Benutzer) +- Tab-Visibility-API für reduzierte Checks bei versteckten Tabs +- Responsive Design für Session-Modals +- Integration mit bestehendem Toast-System + +**Cross-Browser-Kompatibilität:** +- Moderne Fetch-API mit Fallbacks +- ES6-Klassen mit Transpilation-Support +- Mobile-optimierte Modal-Dialoge + +### Sicherheitsverbesserungen + +- ✅ **Drastisch reduzierte Session-Lebensdauer**: 2 Stunden statt 7 Tage +- ✅ **Automatische Inaktivitäts-Erkennung**: 30min für User, 60min für Admins +- ✅ **Session-Hijacking-Schutz**: IP/User-Agent-Monitoring +- ✅ **Sichere Session-Cleanup**: Vollständige Bereinigung bei Logout +- ✅ **Role-basierte Sicherheit**: Verschiedene Timeouts je nach Berechtigung + +### UX-Verbesserungen + +- ✅ **Proaktive Benutzer-Warnungen**: 5 Minuten vor Ablauf +- ✅ **Ein-Klick-Verlängerung**: Session um 30 Minuten verlängern +- ✅ **Graceful Logout**: Sanfte Weiterleitung ohne abrupte Unterbrechung +- ✅ **Transparente Kommunikation**: Klare Benachrichtigungen über Session-Status +- ✅ **Mobile-optimiert**: Responsive Modals und Touch-freundliche Buttons + +### Funktionalität nach der Behebung + +- ✅ **Automatische Abmeldung nach 30/60 Minuten Inaktivität** +- ✅ **Benutzerfreundliche Warnungen 5 Minuten vor Ablauf** +- ✅ **Heartbeat-System hält aktive Sessions am Leben** +- ✅ **Session-Verlängerung per Modal-Dialog** +- ✅ **Sicherheits-Monitoring mit IP/User-Agent-Tracking** +- ✅ **Graceful Session-Management ohne abrupte Unterbrechungen** +- ✅ **Production-ready mit umfassendem Error-Handling** + +**Status:** Automatisches Session-Management vollständig implementiert und production-ready + +## ✅ 30.05.2025 21:45 - Python Syntax-Fehler in Job-Management behoben + +### Problem + +Python-Anwendung startete nicht aufgrund von Einrückungsfehlern: +``` +IndentationError: expected an indented block after 'try' statement on line 2301 +``` + +Mehrere Job-Management-Funktionen hatten Syntax-Fehler: +- `cancel_job()` - Fehlende/falsche Einrückung nach try-Statement +- `start_job()` - Einrückungsfehler in except-Blöcken +- `pause_job()` - Inkonsistente Einrückung +- `resume_job()` - Syntax-Probleme in try/except-Strukturen + +### Root-Cause-Analyse + +**Entstehung der Syntax-Fehler:** +- Während der Session-Management-Implementierung entstanden Einrückungsfehler +- Copy-Paste-Operationen führten zu inkonsistenter Einrückung +- Mehrere try/except-Blöcke waren nicht ordnungsgemäß strukturiert +- Python-Parser konnte die Funktionen nicht interpretieren + +### Implementierte Lösung + +**Systematische Syntax-Bereinigung:** + +```python +# Korrekte Struktur wiederhergestellt +@app.route('/api/jobs//cancel', methods=['POST']) +@login_required +@job_owner_required +def cancel_job(job_id): + """Bricht einen Job ab.""" + try: + db_session = get_db_session() + job = db_session.query(Job).get(job_id) + + if not job: + db_session.close() + return jsonify({"error": "Job nicht gefunden"}), 404 + + # Weitere Logik mit korrekter Einrückung... + + except Exception as e: + jobs_logger.error(f"Fehler beim Abbrechen des Jobs {job_id}: {str(e)}") + return jsonify({"error": "Interner Serverfehler"}), 500 +``` + +**Behobene Funktionen:** +- ✅ `cancel_job()` - Korrekte try/except-Struktur +- ✅ `start_job()` - Einrückung in allen Blöcken korrigiert +- ✅ `pause_job()` - Syntax-Fehler behoben +- ✅ `resume_job()` - Komplette Umstrukturierung für Lesbarkeit + +**Zusätzliche Verbesserungen:** +- Konsistente 4-Leerzeichen-Einrückung durchgängig +- Ordnungsgemäße Schachtelung von try/except-Blöcken +- Korrekte Indentation für if/else-Strukturen +- Python-PEP8-konforme Formatierung + +### Technische Details + +**Fehlerarten behoben:** +- `IndentationError`: Falsche/fehlende Einrückung nach Statements +- `SyntaxError`: Unvollständige try/except-Strukturen +- `UnexpectedIndentationError`: Inkonsistente Einrückungstiefe +- Mixed Indentation: Kombination aus Tabs und Leerzeichen + +**Verifikation:** +- Python-Syntax-Check erfolgreich: `python -m py_compile app.py` +- Alle Job-Management-APIs funktionsfähig +- Session-Management-Integration intakt +- Keine weiteren Linter-Fehler + +### Funktionalität nach der Behebung + +- ✅ **Anwendung startet ordnungsgemäß**: Keine Syntax-Fehler mehr +- ✅ **Job-Management APIs funktional**: cancel, start, pause, resume +- ✅ **Session-Management aktiv**: Heartbeat, status, extend APIs +- ✅ **Error-Handling robust**: Umfassende try/except-Strukturen +- ✅ **Code-Qualität verbessert**: PEP8-konforme Formatierung + +**Status:** Alle Python-Syntax-Fehler behoben, Anwendung production-ready \ No newline at end of file diff --git a/backend/app/app.py b/backend/app/app.py index 47dece91..cd59be89 100644 --- a/backend/app/app.py +++ b/backend/app/app.py @@ -324,23 +324,23 @@ def login(): remember_me = False try: - if is_json_request: + if is_json_request: # JSON-Request verarbeiten try: data = request.get_json(force=True) or {} username = data.get("username") or data.get("email") - password = data.get("password") - remember_me = data.get("remember_me", False) + password = data.get("password") + remember_me = data.get("remember_me", False) except Exception as json_error: auth_logger.warning(f"JSON-Parsing fehlgeschlagen: {str(json_error)}") # Fallback zu Form-Daten username = request.form.get("email") password = request.form.get("password") remember_me = request.form.get("remember_me") == "on" - else: + else: # Form-Request verarbeiten username = request.form.get("email") - password = request.form.get("password") + password = request.form.get("password") remember_me = request.form.get("remember_me") == "on" # Zusätzlicher Fallback für verschiedene Feldnamen @@ -4500,235 +4500,6 @@ def cleanup_temp_files(): app_logger.error(f"Fehler beim Aufräumen temporärer Dateien: {str(e)}") return jsonify({'error': f'Fehler beim Aufräumen: {str(e)}'}), 500 -# ===== STARTUP UND MAIN ===== -if __name__ == "__main__": - import sys - import signal - import os - - # Debug-Modus prüfen - debug_mode = len(sys.argv) > 1 and sys.argv[1] == "--debug" - - # Windows-spezifische Umgebungsvariablen setzen für bessere Flask-Kompatibilität - if os.name == 'nt' and debug_mode: - # Entferne problematische Werkzeug-Variablen - os.environ.pop('WERKZEUG_SERVER_FD', None) - os.environ.pop('WERKZEUG_RUN_MAIN', None) - - # Setze saubere Umgebung - os.environ['FLASK_ENV'] = 'development' - os.environ['PYTHONIOENCODING'] = 'utf-8' - os.environ['PYTHONUTF8'] = '1' - - # Windows-spezifisches Signal-Handling für ordnungsgemäßes Shutdown - def signal_handler(sig, frame): - """Signal-Handler für ordnungsgemäßes Shutdown.""" - app_logger.warning(f"🛑 Signal {sig} empfangen - fahre System herunter...") - try: - # Queue Manager stoppen - app_logger.info("🔄 Beende Queue Manager...") - stop_queue_manager() - - # Scheduler stoppen falls aktiviert - if SCHEDULER_ENABLED and scheduler: - try: - scheduler.stop() - app_logger.info("Job-Scheduler gestoppt") - except Exception as e: - app_logger.error(f"Fehler beim Stoppen des Schedulers: {str(e)}") - - # ===== DATENBANKVERBINDUNGEN ORDNUNGSGEMÄSS SCHLIESSEN ===== - app_logger.info("💾 Führe Datenbank-Cleanup durch...") - try: - from models import get_db_session, create_optimized_engine - from sqlalchemy import text - - # WAL-Checkpoint ausführen um .shm und .wal Dateien zu bereinigen - engine = create_optimized_engine() - - with engine.connect() as conn: - # Vollständiger WAL-Checkpoint (TRUNCATE-Modus) - app_logger.info("📝 Führe WAL-Checkpoint durch...") - result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() - - if result: - app_logger.info(f"WAL-Checkpoint abgeschlossen: {result[1]} Seiten übertragen, {result[2]} Seiten zurückgesetzt") - - # Alle pending Transaktionen committen - conn.commit() - - # Journal-Mode zu DELETE wechseln (entfernt .wal/.shm Dateien) - app_logger.info("📁 Schalte Journal-Mode um...") - conn.execute(text("PRAGMA journal_mode=DELETE")) - - # Optimize und Vacuum für sauberen Zustand - conn.execute(text("PRAGMA optimize")) - conn.execute(text("VACUUM")) - - conn.commit() - - # Engine-Connection-Pool schließen - engine.dispose() - - app_logger.info("✅ Datenbank-Cleanup abgeschlossen - WAL-Dateien sollten verschwunden sein") - - except Exception as db_error: - app_logger.error(f"❌ Fehler beim Datenbank-Cleanup: {str(db_error)}") - - app_logger.info("✅ Shutdown abgeschlossen") - sys.exit(0) - except Exception as e: - app_logger.error(f"❌ Fehler beim Shutdown: {str(e)}") - sys.exit(1) - - - # Signal-Handler registrieren (Windows-kompatibel) - if os.name == 'nt': # Windows - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - # Zusätzlich für Flask-Development-Server - signal.signal(signal.SIGBREAK, signal_handler) - else: # Unix/Linux - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGHUP, signal_handler) - - try: - # Datenbank initialisieren - init_database() - create_initial_admin() - - # Template-Hilfsfunktionen registrieren - register_template_helpers(app) - - # Drucker-Monitor Steckdosen-Initialisierung beim Start - try: - app_logger.info("🖨️ Starte automatische Steckdosen-Initialisierung...") - initialization_results = printer_monitor.initialize_all_outlets_on_startup() - - if initialization_results: - success_count = sum(1 for success in initialization_results.values() if success) - total_count = len(initialization_results) - app_logger.info(f"✅ Steckdosen-Initialisierung: {success_count}/{total_count} Drucker erfolgreich") - - if success_count < total_count: - app_logger.warning(f"⚠️ {total_count - success_count} Drucker konnten nicht initialisiert werden") - else: - app_logger.info("ℹ️ Keine Drucker zur Initialisierung gefunden") - - except Exception as e: - app_logger.error(f"❌ Fehler bei automatischer Steckdosen-Initialisierung: {str(e)}") - - # Queue-Manager für automatische Drucker-Überwachung starten - # Nur im Produktionsmodus starten (nicht im Debug-Modus) - if not debug_mode: - try: - queue_manager = start_queue_manager() - app_logger.info("✅ Printer Queue Manager erfolgreich gestartet") - - # Verbesserte Shutdown-Handler registrieren - def cleanup_queue_manager(): - try: - app_logger.info("🔄 Beende Queue Manager...") - stop_queue_manager() - except Exception as e: - app_logger.error(f"❌ Fehler beim Queue Manager Cleanup: {str(e)}") - - atexit.register(cleanup_queue_manager) - - # ===== DATENBANK-CLEANUP BEIM PROGRAMMENDE ===== - def cleanup_database(): - """Führt Datenbank-Cleanup beim normalen Programmende aus.""" - try: - app_logger.info("💾 Führe finales Datenbank-Cleanup durch...") - from models import create_optimized_engine - from sqlalchemy import text - - engine = create_optimized_engine() - - with engine.connect() as conn: - # WAL-Checkpoint für sauberes Beenden - result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() - if result and result[1] > 0: - app_logger.info(f"Final WAL-Checkpoint: {result[1]} Seiten übertragen") - - # Journal-Mode umschalten um .wal/.shm Dateien zu entfernen - conn.execute(text("PRAGMA journal_mode=DELETE")) - conn.commit() - - # Connection-Pool ordnungsgemäß schließen - engine.dispose() - app_logger.info("✅ Finales Datenbank-Cleanup abgeschlossen") - - except Exception as e: - app_logger.error(f"❌ Fehler beim finalen Datenbank-Cleanup: {str(e)}") - - atexit.register(cleanup_database) - - except Exception as e: - app_logger.error(f"❌ Fehler beim Starten des Queue-Managers: {str(e)}") - else: - app_logger.info("🔄 Debug-Modus: Queue Manager deaktiviert für Entwicklung") - - # Scheduler starten (falls aktiviert) - if SCHEDULER_ENABLED: - try: - scheduler.start() - app_logger.info("Job-Scheduler gestartet") - except Exception as e: - app_logger.error(f"Fehler beim Starten des Schedulers: {str(e)}") - - if debug_mode: - # Debug-Modus: HTTP auf Port 5000 - app_logger.info("Starte Debug-Server auf 0.0.0.0:5000 (HTTP)") - - # Windows-spezifische Flask-Konfiguration - run_kwargs = { - "host": "0.0.0.0", - "port": 5000, - "debug": True, - "threaded": True - } - - if os.name == 'nt': - # Windows: Deaktiviere Auto-Reload um WERKZEUG_SERVER_FD Fehler zu vermeiden - run_kwargs["use_reloader"] = False - run_kwargs["passthrough_errors"] = False - app_logger.info("Windows-Debug-Modus: Auto-Reload deaktiviert") - - app.run(**run_kwargs) - else: - # Produktions-Modus: HTTPS auf Port 443 - ssl_context = get_ssl_context() - - if ssl_context: - app_logger.info("Starte HTTPS-Server auf 0.0.0.0:443") - app.run( - host="0.0.0.0", - port=443, - debug=False, - ssl_context=ssl_context, - threaded=True - ) - else: - app_logger.info("Starte HTTP-Server auf 0.0.0.0:8080") - app.run( - host="0.0.0.0", - port=8080, - debug=False, - threaded=True - ) - except KeyboardInterrupt: - app_logger.info("🔄 Tastatur-Unterbrechung empfangen - beende Anwendung...") - signal_handler(signal.SIGINT, None) - except Exception as e: - app_logger.error(f"Fehler beim Starten der Anwendung: {str(e)}") - # Cleanup bei Fehler - try: - stop_queue_manager() - except: - pass - sys.exit(1) # ===== WEITERE API-ROUTEN ===== @@ -5034,4 +4805,817 @@ def extend_session(): }) except Exception as e: auth_logger.error(f"Fehler beim Verlängern der Session: {str(e)}") - return jsonify({"error": "Session-Verlängerung fehlgeschlagen"}), 500 \ No newline at end of file + return jsonify({"error": "Session-Verlängerung fehlgeschlagen"}), 500 + + +# ===== STARTUP UND MAIN ===== +if __name__ == "__main__": + import sys + import signal + import os + + # Debug-Modus prüfen + debug_mode = len(sys.argv) > 1 and sys.argv[1] == "--debug" + + # Windows-spezifische Umgebungsvariablen setzen für bessere Flask-Kompatibilität + if os.name == 'nt' and debug_mode: + # Entferne problematische Werkzeug-Variablen + os.environ.pop('WERKZEUG_SERVER_FD', None) + os.environ.pop('WERKZEUG_RUN_MAIN', None) + + # Setze saubere Umgebung + os.environ['FLASK_ENV'] = 'development' + os.environ['PYTHONIOENCODING'] = 'utf-8' + os.environ['PYTHONUTF8'] = '1' + + # Windows-spezifisches Signal-Handling für ordnungsgemäßes Shutdown + def signal_handler(sig, frame): + """Signal-Handler für ordnungsgemäßes Shutdown.""" + app_logger.warning(f"🛑 Signal {sig} empfangen - fahre System herunter...") + try: + # Queue Manager stoppen + app_logger.info("🔄 Beende Queue Manager...") + stop_queue_manager() + + # Scheduler stoppen falls aktiviert + if SCHEDULER_ENABLED and scheduler: + try: + scheduler.stop() + app_logger.info("Job-Scheduler gestoppt") + except Exception as e: + app_logger.error(f"Fehler beim Stoppen des Schedulers: {str(e)}") + + # ===== DATENBANKVERBINDUNGEN ORDNUNGSGEMÄSS SCHLIESSEN ===== + app_logger.info("💾 Führe Datenbank-Cleanup durch...") + try: + from models import get_db_session, create_optimized_engine + from sqlalchemy import text + + # WAL-Checkpoint ausführen um .shm und .wal Dateien zu bereinigen + engine = create_optimized_engine() + + with engine.connect() as conn: + # Vollständiger WAL-Checkpoint (TRUNCATE-Modus) + app_logger.info("📝 Führe WAL-Checkpoint durch...") + result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() + + if result: + app_logger.info(f"WAL-Checkpoint abgeschlossen: {result[1]} Seiten übertragen, {result[2]} Seiten zurückgesetzt") + + # Alle pending Transaktionen committen + conn.commit() + + # Journal-Mode zu DELETE wechseln (entfernt .wal/.shm Dateien) + app_logger.info("📁 Schalte Journal-Mode um...") + conn.execute(text("PRAGMA journal_mode=DELETE")) + + # Optimize und Vacuum für sauberen Zustand + conn.execute(text("PRAGMA optimize")) + conn.execute(text("VACUUM")) + + conn.commit() + + # Engine-Connection-Pool schließen + engine.dispose() + + app_logger.info("✅ Datenbank-Cleanup abgeschlossen - WAL-Dateien sollten verschwunden sein") + + except Exception as db_error: + app_logger.error(f"❌ Fehler beim Datenbank-Cleanup: {str(db_error)}") + + app_logger.info("✅ Shutdown abgeschlossen") + sys.exit(0) + except Exception as e: + app_logger.error(f"❌ Fehler beim Shutdown: {str(e)}") + sys.exit(1) + + # Signal-Handler registrieren (Windows-kompatibel) + if os.name == 'nt': # Windows + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + # Zusätzlich für Flask-Development-Server + signal.signal(signal.SIGBREAK, signal_handler) + else: # Unix/Linux + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGHUP, signal_handler) + + try: + # Datenbank initialisieren + init_database() + create_initial_admin() + + # Template-Hilfsfunktionen registrieren + register_template_helpers(app) + + # Drucker-Monitor Steckdosen-Initialisierung beim Start + try: + app_logger.info("🖨️ Starte automatische Steckdosen-Initialisierung...") + initialization_results = printer_monitor.initialize_all_outlets_on_startup() + + if initialization_results: + success_count = sum(1 for success in initialization_results.values() if success) + total_count = len(initialization_results) + app_logger.info(f"✅ Steckdosen-Initialisierung: {success_count}/{total_count} Drucker erfolgreich") + + if success_count < total_count: + app_logger.warning(f"⚠️ {total_count - success_count} Drucker konnten nicht initialisiert werden") + else: + app_logger.info("ℹ️ Keine Drucker zur Initialisierung gefunden") + + except Exception as e: + app_logger.error(f"❌ Fehler bei automatischer Steckdosen-Initialisierung: {str(e)}") + + # Queue-Manager für automatische Drucker-Überwachung starten + # Nur im Produktionsmodus starten (nicht im Debug-Modus) + if not debug_mode: + try: + queue_manager = start_queue_manager() + app_logger.info("✅ Printer Queue Manager erfolgreich gestartet") + + # Verbesserte Shutdown-Handler registrieren + def cleanup_queue_manager(): + try: + app_logger.info("🔄 Beende Queue Manager...") + stop_queue_manager() + except Exception as e: + app_logger.error(f"❌ Fehler beim Queue Manager Cleanup: {str(e)}") + + atexit.register(cleanup_queue_manager) + + # ===== DATENBANK-CLEANUP BEIM PROGRAMMENDE ===== + def cleanup_database(): + """Führt Datenbank-Cleanup beim normalen Programmende aus.""" + try: + app_logger.info("💾 Führe finales Datenbank-Cleanup durch...") + from models import create_optimized_engine + from sqlalchemy import text + + engine = create_optimized_engine() + + with engine.connect() as conn: + # WAL-Checkpoint für sauberes Beenden + result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() + if result and result[1] > 0: + app_logger.info(f"Final WAL-Checkpoint: {result[1]} Seiten übertragen") + + # Journal-Mode umschalten um .wal/.shm Dateien zu entfernen + conn.execute(text("PRAGMA journal_mode=DELETE")) + conn.commit() + + # Connection-Pool ordnungsgemäß schließen + engine.dispose() + app_logger.info("✅ Finales Datenbank-Cleanup abgeschlossen") + + except Exception as e: + app_logger.error(f"❌ Fehler beim finalen Datenbank-Cleanup: {str(e)}") + + atexit.register(cleanup_database) + + except Exception as e: + app_logger.error(f"❌ Fehler beim Starten des Queue-Managers: {str(e)}") + else: + app_logger.info("🔄 Debug-Modus: Queue Manager deaktiviert für Entwicklung") + + # Scheduler starten (falls aktiviert) + if SCHEDULER_ENABLED: + try: + scheduler.start() + app_logger.info("Job-Scheduler gestartet") + except Exception as e: + app_logger.error(f"Fehler beim Starten des Schedulers: {str(e)}") + + if debug_mode: + # Debug-Modus: HTTP auf Port 5000 + app_logger.info("Starte Debug-Server auf 0.0.0.0:5000 (HTTP)") + + # Windows-spezifische Flask-Konfiguration + run_kwargs = { + "host": "0.0.0.0", + "port": 5000, + "debug": True, + "threaded": True + } + + if os.name == 'nt': + # Windows: Deaktiviere Auto-Reload um WERKZEUG_SERVER_FD Fehler zu vermeiden + run_kwargs["use_reloader"] = False + run_kwargs["passthrough_errors"] = False + app_logger.info("Windows-Debug-Modus: Auto-Reload deaktiviert") + + app.run(**run_kwargs) + else: + # Produktions-Modus: HTTPS auf Port 443 + ssl_context = get_ssl_context() + + if ssl_context: + app_logger.info("Starte HTTPS-Server auf 0.0.0.0:443") + app.run( + host="0.0.0.0", + port=443, + debug=False, + ssl_context=ssl_context, + threaded=True + ) + else: + app_logger.info("Starte HTTP-Server auf 0.0.0.0:8080") + app.run( + host="0.0.0.0", + port=8080, + debug=False, + threaded=True + ) + except KeyboardInterrupt: + app_logger.info("🔄 Tastatur-Unterbrechung empfangen - beende Anwendung...") + signal_handler(signal.SIGINT, None) + except Exception as e: + app_logger.error(f"Fehler beim Starten der Anwendung: {str(e)}") + # Cleanup bei Fehler + try: + stop_queue_manager() + except: + pass + sys.exit(1) + +# ===== GASTANTRÄGE API-ROUTEN ===== + +@app.route('/api/admin/guest-requests/test', methods=['GET']) +def test_admin_guest_requests(): + """Test-Endpunkt für Guest Requests Routing""" + app_logger.info("Test-Route /api/admin/guest-requests/test aufgerufen") + return jsonify({ + 'success': True, + 'message': 'Test-Route funktioniert', + 'user_authenticated': current_user.is_authenticated, + 'user_is_admin': current_user.is_admin if current_user.is_authenticated else False + }) + +@app.route('/api/admin/guest-requests', methods=['GET']) +@admin_required +def get_admin_guest_requests(): + """Gibt alle Gastaufträge für Admin-Verwaltung zurück""" + try: + app_logger.info(f"API-Aufruf /api/admin/guest-requests von User {current_user.id if current_user.is_authenticated else 'Anonymous'}") + + db_session = get_db_session() + + # Parameter auslesen + status = request.args.get('status', 'all') + page = int(request.args.get('page', 0)) + page_size = int(request.args.get('page_size', 50)) + search = request.args.get('search', '') + sort = request.args.get('sort', 'newest') + urgent = request.args.get('urgent', 'all') + + # Basis-Query + query = db_session.query(GuestRequest) + + # Status-Filter + if status != 'all': + query = query.filter(GuestRequest.status == status) + + # Suchfilter + if search: + search_term = f"%{search}%" + query = query.filter( + (GuestRequest.name.ilike(search_term)) | + (GuestRequest.email.ilike(search_term)) | + (GuestRequest.file_name.ilike(search_term)) | + (GuestRequest.reason.ilike(search_term)) + ) + + # Dringlichkeitsfilter + if urgent == 'urgent': + urgent_cutoff = datetime.now() - timedelta(hours=24) + query = query.filter( + GuestRequest.status == 'pending', + GuestRequest.created_at < urgent_cutoff + ) + elif urgent == 'normal': + urgent_cutoff = datetime.now() - timedelta(hours=24) + query = query.filter( + (GuestRequest.status != 'pending') | + (GuestRequest.created_at >= urgent_cutoff) + ) + + # Gesamtanzahl vor Pagination + total = query.count() + + # Sortierung + if sort == 'oldest': + query = query.order_by(GuestRequest.created_at.asc()) + elif sort == 'urgent': + # Urgent first, then by creation date desc + query = query.order_by(GuestRequest.created_at.asc()).order_by(GuestRequest.created_at.desc()) + else: # newest + query = query.order_by(GuestRequest.created_at.desc()) + + # Pagination + offset = page * page_size + requests = query.offset(offset).limit(page_size).all() + + # Statistiken berechnen + stats = { + 'total': db_session.query(GuestRequest).count(), + 'pending': db_session.query(GuestRequest).filter(GuestRequest.status == 'pending').count(), + 'approved': db_session.query(GuestRequest).filter(GuestRequest.status == 'approved').count(), + 'rejected': db_session.query(GuestRequest).filter(GuestRequest.status == 'rejected').count(), + } + + # Requests zu Dictionary konvertieren + requests_data = [] + for req in requests: + # Priorität berechnen + now = datetime.now() + hours_old = (now - req.created_at).total_seconds() / 3600 if req.created_at else 0 + is_urgent = hours_old > 24 and req.status == 'pending' + + request_data = { + 'id': req.id, + 'name': req.name, + 'email': req.email, + 'file_name': req.file_name, + 'file_path': req.file_path, + 'duration_minutes': req.duration_minutes, + 'copies': req.copies, + 'reason': req.reason, + 'status': req.status, + 'created_at': req.created_at.isoformat() if req.created_at else None, + 'updated_at': req.updated_at.isoformat() if req.updated_at else None, + 'approved_at': req.approved_at.isoformat() if req.approved_at else None, + 'rejected_at': req.rejected_at.isoformat() if req.rejected_at else None, + 'approval_notes': req.approval_notes, + 'rejection_reason': req.rejection_reason, + 'is_urgent': is_urgent, + 'hours_old': round(hours_old, 1), + 'author_ip': req.author_ip + } + requests_data.append(request_data) + + db_session.close() + + app_logger.info(f"Admin-Gastaufträge geladen: {len(requests_data)} von {total} (Status: {status})") + + return jsonify({ + 'success': True, + 'requests': requests_data, + 'stats': stats, + 'total': total, + 'page': page, + 'page_size': page_size, + 'has_more': offset + page_size < total + }) + + except Exception as e: + app_logger.error(f"Fehler beim Laden der Admin-Gastaufträge: {str(e)}", exc_info=True) + return jsonify({ + 'success': False, + 'message': f'Fehler beim Laden der Gastaufträge: {str(e)}' + }), 500 + +@app.route('/api/guest-requests//approve', methods=['POST']) +@admin_required +def approve_guest_request(request_id): + """Genehmigt einen Gastauftrag""" + try: + db_session = get_db_session() + + guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first() + + if not guest_request: + db_session.close() + return jsonify({ + 'success': False, + 'message': 'Gastauftrag nicht gefunden' + }), 404 + + if guest_request.status != 'pending': + db_session.close() + return jsonify({ + 'success': False, + 'message': f'Gastauftrag kann im Status "{guest_request.status}" nicht genehmigt werden' + }), 400 + + # Daten aus Request Body + data = request.get_json() or {} + notes = data.get('notes', '') + printer_id = data.get('printer_id') + + # Status aktualisieren + guest_request.status = 'approved' + guest_request.approved_at = datetime.now() + guest_request.approved_by = current_user.id + guest_request.approval_notes = notes + guest_request.updated_at = datetime.now() + + # Falls Drucker zugewiesen werden soll + if printer_id: + printer = db_session.query(Printer).filter(Printer.id == printer_id).first() + if printer: + guest_request.assigned_printer_id = printer_id + + # OTP-Code generieren für den Gast + import secrets + otp_code = ''.join([str(secrets.randbelow(10)) for _ in range(6)]) + guest_request.otp_code = otp_code + guest_request.otp_expires_at = datetime.now() + timedelta(hours=24) + + db_session.commit() + + # Benachrichtigung an den Gast senden (falls E-Mail verfügbar) + if guest_request.email: + try: + # Hier würde normalerweise eine E-Mail gesendet werden + app_logger.info(f"E-Mail-Benachrichtigung würde an {guest_request.email} gesendet (OTP: {otp_code})") + except Exception as e: + app_logger.warning(f"Fehler beim Senden der E-Mail-Benachrichtigung: {str(e)}") + + db_session.close() + + app_logger.info(f"Gastauftrag {request_id} von Admin {current_user.id} genehmigt (OTP: {otp_code})") + + return jsonify({ + 'success': True, + 'message': 'Gastauftrag erfolgreich genehmigt', + 'otp_code': otp_code, + 'expires_at': (datetime.now() + timedelta(hours=24)).isoformat() + }) + + except Exception as e: + app_logger.error(f"Fehler beim Genehmigen des Gastauftrags {request_id}: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Genehmigen: {str(e)}' + }), 500 + +@app.route('/api/guest-requests//reject', methods=['POST']) +@admin_required +def reject_guest_request(request_id): + """Lehnt einen Gastauftrag ab""" + try: + db_session = get_db_session() + + guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first() + + if not guest_request: + db_session.close() + return jsonify({ + 'success': False, + 'message': 'Gastauftrag nicht gefunden' + }), 404 + + if guest_request.status != 'pending': + db_session.close() + return jsonify({ + 'success': False, + 'message': f'Gastauftrag kann im Status "{guest_request.status}" nicht abgelehnt werden' + }), 400 + + # Daten aus Request Body + data = request.get_json() or {} + reason = data.get('reason', '').strip() + + if not reason: + db_session.close() + return jsonify({ + 'success': False, + 'message': 'Ablehnungsgrund ist erforderlich' + }), 400 + + # Status aktualisieren + guest_request.status = 'rejected' + guest_request.rejected_at = datetime.now() + guest_request.rejected_by = current_user.id + guest_request.rejection_reason = reason + guest_request.updated_at = datetime.now() + + db_session.commit() + + # Benachrichtigung an den Gast senden (falls E-Mail verfügbar) + if guest_request.email: + try: + # Hier würde normalerweise eine E-Mail gesendet werden + app_logger.info(f"Ablehnungs-E-Mail würde an {guest_request.email} gesendet (Grund: {reason})") + except Exception as e: + app_logger.warning(f"Fehler beim Senden der Ablehnungs-E-Mail: {str(e)}") + + db_session.close() + + app_logger.info(f"Gastauftrag {request_id} von Admin {current_user.id} abgelehnt (Grund: {reason})") + + return jsonify({ + 'success': True, + 'message': 'Gastauftrag erfolgreich abgelehnt' + }) + + except Exception as e: + app_logger.error(f"Fehler beim Ablehnen des Gastauftrags {request_id}: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Ablehnen: {str(e)}' + }), 500 + +@app.route('/api/guest-requests/', methods=['DELETE']) +@admin_required +def delete_guest_request(request_id): + """Löscht einen Gastauftrag""" + try: + db_session = get_db_session() + + guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first() + + if not guest_request: + db_session.close() + return jsonify({ + 'success': False, + 'message': 'Gastauftrag nicht gefunden' + }), 404 + + # Datei löschen falls vorhanden + if guest_request.file_path and os.path.exists(guest_request.file_path): + try: + os.remove(guest_request.file_path) + app_logger.info(f"Datei {guest_request.file_path} für Gastauftrag {request_id} gelöscht") + except Exception as e: + app_logger.warning(f"Fehler beim Löschen der Datei: {str(e)}") + + # Gastauftrag aus Datenbank löschen + request_name = guest_request.name + db_session.delete(guest_request) + db_session.commit() + db_session.close() + + app_logger.info(f"Gastauftrag {request_id} ({request_name}) von Admin {current_user.id} gelöscht") + + return jsonify({ + 'success': True, + 'message': 'Gastauftrag erfolgreich gelöscht' + }) + + except Exception as e: + app_logger.error(f"Fehler beim Löschen des Gastauftrags {request_id}: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen: {str(e)}' + }), 500 + +@app.route('/api/guest-requests/', methods=['GET']) +@admin_required +def get_guest_request_detail(request_id): + """Gibt Details eines spezifischen Gastauftrags zurück""" + try: + db_session = get_db_session() + + guest_request = db_session.query(GuestRequest).filter(GuestRequest.id == request_id).first() + + if not guest_request: + db_session.close() + return jsonify({ + 'success': False, + 'message': 'Gastauftrag nicht gefunden' + }), 404 + + # Detaildaten zusammenstellen + request_data = { + 'id': guest_request.id, + 'name': guest_request.name, + 'email': guest_request.email, + 'file_name': guest_request.file_name, + 'file_path': guest_request.file_path, + 'file_size': None, + 'duration_minutes': guest_request.duration_minutes, + 'copies': guest_request.copies, + 'reason': guest_request.reason, + 'status': guest_request.status, + 'created_at': guest_request.created_at.isoformat() if guest_request.created_at else None, + 'updated_at': guest_request.updated_at.isoformat() if guest_request.updated_at else None, + 'approved_at': guest_request.approved_at.isoformat() if guest_request.approved_at else None, + 'rejected_at': guest_request.rejected_at.isoformat() if guest_request.rejected_at else None, + 'approval_notes': guest_request.approval_notes, + 'rejection_reason': guest_request.rejection_reason, + 'otp_code': guest_request.otp_code, + 'otp_expires_at': guest_request.otp_expires_at.isoformat() if guest_request.otp_expires_at else None, + 'author_ip': guest_request.author_ip + } + + # Dateigröße ermitteln + if guest_request.file_path and os.path.exists(guest_request.file_path): + try: + file_size = os.path.getsize(guest_request.file_path) + request_data['file_size'] = file_size + request_data['file_size_mb'] = round(file_size / (1024 * 1024), 2) + except Exception as e: + app_logger.warning(f"Fehler beim Ermitteln der Dateigröße: {str(e)}") + + # Bearbeiter-Informationen hinzufügen + if guest_request.approved_by: + approved_by_user = db_session.query(User).filter(User.id == guest_request.approved_by).first() + if approved_by_user: + request_data['approved_by_name'] = approved_by_user.name or approved_by_user.username + + if guest_request.rejected_by: + rejected_by_user = db_session.query(User).filter(User.id == guest_request.rejected_by).first() + if rejected_by_user: + request_data['rejected_by_name'] = rejected_by_user.name or rejected_by_user.username + + # Zugewiesener Drucker + if hasattr(guest_request, 'assigned_printer_id') and guest_request.assigned_printer_id: + assigned_printer = db_session.query(Printer).filter(Printer.id == guest_request.assigned_printer_id).first() + if assigned_printer: + request_data['assigned_printer'] = { + 'id': assigned_printer.id, + 'name': assigned_printer.name, + 'location': assigned_printer.location, + 'status': assigned_printer.status + } + + db_session.close() + + return jsonify({ + 'success': True, + 'request': request_data + }) + + except Exception as e: + app_logger.error(f"Fehler beim Abrufen der Gastauftrag-Details {request_id}: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Abrufen der Details: {str(e)}' + }), 500 + +@app.route('/api/admin/guest-requests/stats', methods=['GET']) +@admin_required +def get_guest_requests_stats(): + """Gibt detaillierte Statistiken zu Gastaufträgen zurück""" + try: + db_session = get_db_session() + + # Basis-Statistiken + total = db_session.query(GuestRequest).count() + pending = db_session.query(GuestRequest).filter(GuestRequest.status == 'pending').count() + approved = db_session.query(GuestRequest).filter(GuestRequest.status == 'approved').count() + rejected = db_session.query(GuestRequest).filter(GuestRequest.status == 'rejected').count() + + # Zeitbasierte Statistiken + today = datetime.now().date() + week_ago = datetime.now() - timedelta(days=7) + month_ago = datetime.now() - timedelta(days=30) + + today_requests = db_session.query(GuestRequest).filter( + func.date(GuestRequest.created_at) == today + ).count() + + week_requests = db_session.query(GuestRequest).filter( + GuestRequest.created_at >= week_ago + ).count() + + month_requests = db_session.query(GuestRequest).filter( + GuestRequest.created_at >= month_ago + ).count() + + # Dringende Requests (älter als 24h und pending) + urgent_cutoff = datetime.now() - timedelta(hours=24) + urgent_requests = db_session.query(GuestRequest).filter( + GuestRequest.status == 'pending', + GuestRequest.created_at < urgent_cutoff + ).count() + + # Durchschnittliche Bearbeitungszeit + avg_processing_time = None + try: + processed_requests = db_session.query(GuestRequest).filter( + GuestRequest.status.in_(['approved', 'rejected']), + GuestRequest.updated_at.isnot(None) + ).all() + + if processed_requests: + total_time = sum([ + (req.updated_at - req.created_at).total_seconds() + for req in processed_requests + if req.updated_at and req.created_at + ]) + avg_processing_time = round(total_time / len(processed_requests) / 3600, 2) # Stunden + except Exception as e: + app_logger.warning(f"Fehler beim Berechnen der durchschnittlichen Bearbeitungszeit: {str(e)}") + + # Erfolgsrate + success_rate = 0 + if approved + rejected > 0: + success_rate = round((approved / (approved + rejected)) * 100, 1) + + stats = { + 'total': total, + 'pending': pending, + 'approved': approved, + 'rejected': rejected, + 'urgent': urgent_requests, + 'today': today_requests, + 'week': week_requests, + 'month': month_requests, + 'success_rate': success_rate, + 'avg_processing_time_hours': avg_processing_time, + 'completion_rate': round(((approved + rejected) / total * 100), 1) if total > 0 else 0 + } + + db_session.close() + + return jsonify({ + 'success': True, + 'stats': stats, + 'generated_at': datetime.now().isoformat() + }) + + except Exception as e: + app_logger.error(f"Fehler beim Abrufen der Gastauftrag-Statistiken: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Abrufen der Statistiken: {str(e)}' + }), 500 + +@app.route('/api/admin/guest-requests/export', methods=['GET']) +@admin_required +def export_guest_requests(): + """Exportiert Gastaufträge als CSV""" + try: + db_session = get_db_session() + + # Filter-Parameter + status = request.args.get('status', 'all') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # Query aufbauen + query = db_session.query(GuestRequest) + + if status != 'all': + query = query.filter(GuestRequest.status == status) + + if start_date: + try: + start_dt = datetime.fromisoformat(start_date) + query = query.filter(GuestRequest.created_at >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date) + query = query.filter(GuestRequest.created_at <= end_dt) + except ValueError: + pass + + requests = query.order_by(GuestRequest.created_at.desc()).all() + + # CSV-Daten erstellen + import csv + import io + + output = io.StringIO() + writer = csv.writer(output) + + # Header + writer.writerow([ + 'ID', 'Name', 'E-Mail', 'Datei', 'Status', 'Erstellt am', + 'Dauer (Min)', 'Kopien', 'Begründung', 'Genehmigt am', + 'Abgelehnt am', 'Bearbeitungsnotizen', 'Ablehnungsgrund', 'OTP-Code' + ]) + + # Daten + for req in requests: + writer.writerow([ + req.id, + req.name or '', + req.email or '', + req.file_name or '', + req.status, + req.created_at.strftime('%Y-%m-%d %H:%M:%S') if req.created_at else '', + req.duration_minutes or '', + req.copies or '', + req.reason or '', + req.approved_at.strftime('%Y-%m-%d %H:%M:%S') if req.approved_at else '', + req.rejected_at.strftime('%Y-%m-%d %H:%M:%S') if req.rejected_at else '', + req.approval_notes or '', + req.rejection_reason or '', + req.otp_code or '' + ]) + + db_session.close() + + # Response erstellen + output.seek(0) + filename = f"gastantraege_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + response = make_response(output.getvalue()) + response.headers['Content-Type'] = 'text/csv; charset=utf-8' + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + + app_logger.info(f"Gastaufträge-Export erstellt: {len(requests)} Datensätze") + + return response + + except Exception as e: + app_logger.error(f"Fehler beim Exportieren der Gastaufträge: {str(e)}") + return jsonify({ + 'success': False, + 'message': f'Fehler beim Export: {str(e)}' + }), 500 \ No newline at end of file diff --git a/backend/app/database/myp.db b/backend/app/database/myp.db index 348f4353..4b78cad4 100644 Binary files a/backend/app/database/myp.db and b/backend/app/database/myp.db differ diff --git a/backend/app/database/myp.db-shm b/backend/app/database/myp.db-shm deleted file mode 100644 index 0c0092ba..00000000 Binary files a/backend/app/database/myp.db-shm and /dev/null differ diff --git a/backend/app/database/myp.db-wal b/backend/app/database/myp.db-wal deleted file mode 100644 index aa27dad0..00000000 Binary files a/backend/app/database/myp.db-wal and /dev/null differ