From f2928b97fceeb8ce54f1f9b375c52b310ce8c7a4 Mon Sep 17 00:00:00 2001 From: Till Tomczak Date: Mon, 2 Jun 2025 14:16:23 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Improved=20backend=20configurati?= =?UTF-8?q?on=20and=20documentation=20=F0=9F=96=A5=EF=B8=8F=F0=9F=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.npmrc | 2 + backend/app.py | 230 +++++++ backend/blueprints/calendar.py | 479 +++++++++++++- backend/docs/DRUCKERKONFLIKT_MANAGEMENT.md | 1 + backend/docs/STECKDOSEN_MONITORING.md | 1 + backend/templates/admin_plug_monitoring.html | 585 +++++++++++++++++ backend/templates/base.html | 9 + backend/utils/conflict_manager.py | 625 +++++++++++++++++++ backend/utils/printer_monitor.py | 143 ++++- 9 files changed, 2067 insertions(+), 8 deletions(-) create mode 100644 backend/docs/DRUCKERKONFLIKT_MANAGEMENT.md create mode 100644 backend/docs/STECKDOSEN_MONITORING.md create mode 100644 backend/templates/admin_plug_monitoring.html create mode 100644 backend/utils/conflict_manager.py diff --git a/backend/.npmrc b/backend/.npmrc index f7e31b9e..eac74830 100644 --- a/backend/.npmrc +++ b/backend/.npmrc @@ -7,3 +7,5 @@ ca[]= cafile=/etc/ssl/certs/ca-certificates.crt strict-ssl=true registry=https://registry.npmjs.org/ +progress=false +loglevel=warn diff --git a/backend/app.py b/backend/app.py index 25b744e5..c9fd7c42 100644 --- a/backend/app.py +++ b/backend/app.py @@ -8367,6 +8367,236 @@ def refresh_dashboard(): 'details': str(e) if app.debug else None }), 500 +# ===== STECKDOSEN-MONITORING API-ROUTEN ===== + +@app.route("/api/admin/plug-monitoring/logs", methods=['GET']) +@login_required +@admin_required +def api_admin_plug_monitoring_logs(): + """ + API-Endpoint für Steckdosen-Monitoring-Logs. + Unterstützt Filterung nach Drucker, Zeitraum und Status. + """ + try: + # Parameter aus Request + printer_id = request.args.get('printer_id', type=int) + hours = request.args.get('hours', default=24, type=int) + status_filter = request.args.get('status') + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=100, type=int) + + # Maximale Grenzen setzen + hours = min(hours, 168) # Maximal 7 Tage + per_page = min(per_page, 1000) # Maximal 1000 Einträge pro Seite + + db_session = get_db_session() + + try: + # Basis-Query + cutoff_time = datetime.now() - timedelta(hours=hours) + query = db_session.query(PlugStatusLog)\ + .filter(PlugStatusLog.timestamp >= cutoff_time)\ + .join(Printer) + + # Drucker-Filter + if printer_id: + query = query.filter(PlugStatusLog.printer_id == printer_id) + + # Status-Filter + if status_filter: + query = query.filter(PlugStatusLog.status == status_filter) + + # Gesamtanzahl für Paginierung + total = query.count() + + # Sortierung und Paginierung + logs = query.order_by(PlugStatusLog.timestamp.desc())\ + .offset((page - 1) * per_page)\ + .limit(per_page)\ + .all() + + # Daten serialisieren + log_data = [] + for log in logs: + log_dict = log.to_dict() + # Zusätzliche berechnete Felder + log_dict['timestamp_relative'] = get_relative_time(log.timestamp) + log_dict['status_icon'] = get_status_icon(log.status) + log_dict['status_color'] = get_status_color(log.status) + log_data.append(log_dict) + + # Paginierungs-Metadaten + has_next = (page * per_page) < total + has_prev = page > 1 + + return jsonify({ + "success": True, + "logs": log_data, + "pagination": { + "page": page, + "per_page": per_page, + "total": total, + "total_pages": (total + per_page - 1) // per_page, + "has_next": has_next, + "has_prev": has_prev + }, + "filters": { + "printer_id": printer_id, + "hours": hours, + "status": status_filter + }, + "generated_at": datetime.now().isoformat() + }) + + finally: + db_session.close() + + except Exception as e: + app_logger.error(f"Fehler beim Abrufen der Steckdosen-Logs: {str(e)}") + return jsonify({ + "success": False, + "error": "Fehler beim Laden der Steckdosen-Logs", + "details": str(e) if current_user.is_admin else None + }), 500 + +@app.route("/api/admin/plug-monitoring/statistics", methods=['GET']) +@login_required +@admin_required +def api_admin_plug_monitoring_statistics(): + """ + API-Endpoint für Steckdosen-Monitoring-Statistiken. + """ + try: + hours = request.args.get('hours', default=24, type=int) + hours = min(hours, 168) # Maximal 7 Tage + + # Statistiken abrufen + stats = PlugStatusLog.get_status_statistics(hours=hours) + + # Drucker-Namen für die Top-Liste hinzufügen + if stats.get('top_printers'): + db_session = get_db_session() + try: + printer_ids = list(stats['top_printers'].keys()) + printers = db_session.query(Printer.id, Printer.name)\ + .filter(Printer.id.in_(printer_ids))\ + .all() + + printer_names = {p.id: p.name for p in printers} + + # Top-Drucker mit Namen anreichern + top_printers_with_names = [] + for printer_id, count in stats['top_printers'].items(): + top_printers_with_names.append({ + "printer_id": printer_id, + "printer_name": printer_names.get(printer_id, f"Drucker {printer_id}"), + "log_count": count + }) + + stats['top_printers_detailed'] = top_printers_with_names + + finally: + db_session.close() + + return jsonify({ + "success": True, + "statistics": stats + }) + + except Exception as e: + app_logger.error(f"Fehler beim Abrufen der Steckdosen-Statistiken: {str(e)}") + return jsonify({ + "success": False, + "error": "Fehler beim Laden der Statistiken", + "details": str(e) if current_user.is_admin else None + }), 500 + +@app.route("/api/admin/plug-monitoring/cleanup", methods=['POST']) +@login_required +@admin_required +def api_admin_plug_monitoring_cleanup(): + """ + API-Endpoint zum Bereinigen alter Steckdosen-Logs. + """ + try: + data = request.get_json() or {} + days = data.get('days', 30) + days = max(1, min(days, 365)) # Zwischen 1 und 365 Tagen + + # Bereinigung durchführen + deleted_count = PlugStatusLog.cleanup_old_logs(days=days) + + # Erfolg loggen + SystemLog.log_system_event( + level="INFO", + message=f"Steckdosen-Logs bereinigt: {deleted_count} Einträge gelöscht (älter als {days} Tage)", + module="admin_plug_monitoring", + user_id=current_user.id + ) + + app_logger.info(f"Admin {current_user.name} berinigte {deleted_count} Steckdosen-Logs (älter als {days} Tage)") + + return jsonify({ + "success": True, + "deleted_count": deleted_count, + "days": days, + "message": f"Erfolgreich {deleted_count} alte Einträge gelöscht" + }) + + except Exception as e: + app_logger.error(f"Fehler beim Bereinigen der Steckdosen-Logs: {str(e)}") + return jsonify({ + "success": False, + "error": "Fehler beim Bereinigen der Logs", + "details": str(e) if current_user.is_admin else None + }), 500 + +def get_relative_time(timestamp): + """ + Hilfsfunktion für relative Zeitangaben. + """ + if not timestamp: + return "Unbekannt" + + now = datetime.now() + diff = now - timestamp + + if diff.total_seconds() < 60: + return "Gerade eben" + elif diff.total_seconds() < 3600: + minutes = int(diff.total_seconds() / 60) + return f"vor {minutes} Minute{'n' if minutes != 1 else ''}" + elif diff.total_seconds() < 86400: + hours = int(diff.total_seconds() / 3600) + return f"vor {hours} Stunde{'n' if hours != 1 else ''}" + else: + days = int(diff.total_seconds() / 86400) + return f"vor {days} Tag{'en' if days != 1 else ''}" + +def get_status_icon(status): + """ + Hilfsfunktion für Status-Icons. + """ + icons = { + 'connected': '🔌', + 'disconnected': '❌', + 'on': '🟢', + 'off': '🔴' + } + return icons.get(status, '❓') + +def get_status_color(status): + """ + Hilfsfunktion für Status-Farben (CSS-Klassen). + """ + colors = { + 'connected': 'text-blue-600', + 'disconnected': 'text-red-600', + 'on': 'text-green-600', + 'off': 'text-orange-600' + } + return colors.get(status, 'text-gray-600') + # ===== STARTUP UND MAIN ===== if __name__ == "__main__": import sys diff --git a/backend/blueprints/calendar.py b/backend/blueprints/calendar.py index 0afb2ad7..c73206e9 100644 --- a/backend/blueprints/calendar.py +++ b/backend/blueprints/calendar.py @@ -6,6 +6,7 @@ from sqlalchemy import and_, or_, func from models import Job, Printer, User, UserPermission, get_cached_session from utils.logging_config import get_logger +from utils.conflict_manager import conflict_manager, ConflictType, ConflictSeverity calendar_blueprint = Blueprint('calendar', __name__) logger = get_logger("calendar") @@ -838,4 +839,480 @@ def api_export_calendar(): except Exception as e: logger.error(f"Fehler beim Kalender-Export: {str(e)}") - return jsonify({"error": f"Fehler beim Export: {str(e)}"}), 500 \ No newline at end of file + return jsonify({"error": f"Fehler beim Export: {str(e)}"}), 500 + +@calendar_blueprint.route('/api/calendar/check-conflicts', methods=['POST']) +@login_required +def api_check_conflicts(): + """ + Prüft explizit auf Konflikte für eine geplante Reservierung + und gibt detaillierte Konfliktinformationen zurück. + """ + try: + data = request.get_json() + if not data: + return jsonify({"error": "Keine Daten erhalten"}), 400 + + # Pflichtfelder prüfen + required_fields = ['start_time', 'end_time'] + for field in required_fields: + if field not in data: + return jsonify({"error": f"Feld '{field}' fehlt"}), 400 + + # Daten extrahieren + try: + start_time = datetime.fromisoformat(data['start_time']) + end_time = datetime.fromisoformat(data['end_time']) + printer_id = data.get('printer_id') + priority = data.get('priority', 'normal') + duration_minutes = int((end_time - start_time).total_seconds() / 60) + except (ValueError, TypeError) as e: + return jsonify({"error": f"Ungültige Datenformate: {str(e)}"}), 400 + + with get_cached_session() as db_session: + # Job-Daten für Konfliktanalyse vorbereiten + job_data = { + 'printer_id': printer_id, + 'start_time': start_time, + 'end_time': end_time, + 'priority': priority, + 'duration_minutes': duration_minutes + } + + # Konfliktanalyse durchführen + conflicts = conflict_manager.detect_conflicts(job_data, db_session) + + # Konfliktinformationen für Response aufbereiten + conflict_info = [] + total_severity_score = 0 + + for conflict in conflicts: + severity_scores = { + ConflictSeverity.CRITICAL: 4, + ConflictSeverity.HIGH: 3, + ConflictSeverity.MEDIUM: 2, + ConflictSeverity.LOW: 1, + ConflictSeverity.INFO: 0 + } + + total_severity_score += severity_scores.get(conflict.severity, 0) + + # Konflikthafte Jobs laden für Details + conflicting_job_details = [] + for job_id in conflict.conflicting_job_ids: + job = db_session.query(Job).filter_by(id=job_id).first() + if job: + conflicting_job_details.append({ + 'id': job.id, + 'name': job.name, + 'start_time': job.start_at.isoformat() if job.start_at else None, + 'end_time': job.end_at.isoformat() if job.end_at else None, + 'user_name': job.user.name if job.user else "Unbekannt" + }) + + # Drucker-Details laden + printer_info = None + if conflict.affected_printer_id: + printer = db_session.query(Printer).filter_by(id=conflict.affected_printer_id).first() + if printer: + printer_info = { + 'id': printer.id, + 'name': printer.name, + 'location': printer.location, + 'status': printer.status, + 'active': printer.active + } + + conflict_detail = { + 'type': conflict.conflict_type.value, + 'severity': conflict.severity.value, + 'description': conflict.description, + 'estimated_impact': conflict.estimated_impact, + 'auto_resolvable': conflict.auto_resolvable, + 'conflict_start': conflict.conflict_start.isoformat() if conflict.conflict_start else None, + 'conflict_end': conflict.conflict_end.isoformat() if conflict.conflict_end else None, + 'affected_printer': printer_info, + 'conflicting_jobs': conflicting_job_details, + 'suggested_solutions': conflict.suggested_solutions + } + + conflict_info.append(conflict_detail) + + # Gesamtbewertung + has_conflicts = len(conflicts) > 0 + can_proceed = all(c.auto_resolvable for c in conflicts) or len(conflicts) == 0 + + # Empfehlungen basierend auf Konflikten + recommendations = [] + if has_conflicts: + if can_proceed: + recommendations.append({ + 'type': 'auto_resolve', + 'message': 'Konflikte können automatisch gelöst werden', + 'action': 'Automatische Lösung anwenden' + }) + else: + recommendations.append({ + 'type': 'manual_intervention', + 'message': 'Manuelle Anpassung erforderlich', + 'action': 'Zeitraum oder Drucker ändern' + }) + + # Alternative Zeitfenster vorschlagen + if printer_id: + alternative_slots = conflict_manager._find_alternative_time_slots(job_data, db_session) + if alternative_slots: + slot_suggestions = [] + for start, end, confidence in alternative_slots[:3]: + slot_suggestions.append({ + 'start_time': start.isoformat(), + 'end_time': end.isoformat(), + 'confidence': confidence, + 'description': f"{start.strftime('%H:%M')} - {end.strftime('%H:%M')}" + }) + + recommendations.append({ + 'type': 'alternative_times', + 'message': 'Alternative Zeitfenster verfügbar', + 'suggestions': slot_suggestions + }) + + # Alternative Drucker vorschlagen + alternative_printers = conflict_manager._find_alternative_printers(job_data, db_session) + if alternative_printers: + printer_suggestions = [] + for printer_id_alt, confidence in alternative_printers[:3]: + printer = db_session.query(Printer).filter_by(id=printer_id_alt).first() + if printer: + printer_suggestions.append({ + 'printer_id': printer.id, + 'printer_name': printer.name, + 'location': printer.location, + 'confidence': confidence + }) + + recommendations.append({ + 'type': 'alternative_printers', + 'message': 'Alternative Drucker verfügbar', + 'suggestions': printer_suggestions + }) + + logger.info(f"🔍 Konfliktprüfung abgeschlossen: {len(conflicts)} Konflikte, " + f"Schweregrad: {total_severity_score}, Automatisch lösbar: {can_proceed}") + + return jsonify({ + 'has_conflicts': has_conflicts, + 'can_proceed': can_proceed, + 'severity_score': total_severity_score, + 'conflict_count': len(conflicts), + 'conflicts': conflict_info, + 'recommendations': recommendations, + 'summary': { + 'critical_conflicts': len([c for c in conflicts if c.severity == ConflictSeverity.CRITICAL]), + 'high_conflicts': len([c for c in conflicts if c.severity == ConflictSeverity.HIGH]), + 'medium_conflicts': len([c for c in conflicts if c.severity == ConflictSeverity.MEDIUM]), + 'low_conflicts': len([c for c in conflicts if c.severity == ConflictSeverity.LOW]) + } + }) + + except Exception as e: + logger.error(f"❌ Fehler bei Konfliktprüfung: {str(e)}", exc_info=True) + return jsonify({"error": "Fehler bei der Konfliktanalyse"}), 500 + +@calendar_blueprint.route('/api/calendar/resolve-conflicts', methods=['POST']) +@login_required +def api_resolve_conflicts(): + """ + Löst erkannte Konflikte automatisch und erstellt den Job + mit den optimalen Parametern. + """ + try: + data = request.get_json() + if not data: + return jsonify({"error": "Keine Daten erhalten"}), 400 + + # Pflichtfelder prüfen + required_fields = ['start_time', 'end_time', 'title'] + for field in required_fields: + if field not in data: + return jsonify({"error": f"Feld '{field}' fehlt"}), 400 + + # Daten extrahieren + try: + start_time = datetime.fromisoformat(data['start_time']) + end_time = datetime.fromisoformat(data['end_time']) + title = data['title'] + description = data.get('description', '') + printer_id = data.get('printer_id') + priority = data.get('priority', 'normal') + duration_minutes = int((end_time - start_time).total_seconds() / 60) + auto_resolve = data.get('auto_resolve', True) + except (ValueError, TypeError) as e: + return jsonify({"error": f"Ungültige Datenformate: {str(e)}"}), 400 + + with get_cached_session() as db_session: + # Job-Daten für Konfliktanalyse vorbereiten + job_data = { + 'printer_id': printer_id, + 'start_time': start_time, + 'end_time': end_time, + 'priority': priority, + 'duration_minutes': duration_minutes, + 'title': title, + 'description': description + } + + # Konfliktanalyse durchführen + conflicts = conflict_manager.detect_conflicts(job_data, db_session) + + final_job_data = job_data.copy() + resolution_messages = [] + + if conflicts and auto_resolve: + # Konflikte automatisch lösen + resolutions = conflict_manager.resolve_conflicts(conflicts, job_data, db_session) + + # Erste erfolgreiche Lösung anwenden + successful_resolution = next((r for r in resolutions if r.success), None) + + if successful_resolution: + # Job-Parameter basierend auf Lösung anpassen + if successful_resolution.new_printer_id: + final_job_data['printer_id'] = successful_resolution.new_printer_id + if successful_resolution.new_start_time: + final_job_data['start_time'] = successful_resolution.new_start_time + if successful_resolution.new_end_time: + final_job_data['end_time'] = successful_resolution.new_end_time + + resolution_messages.append(successful_resolution.message) + logger.info(f"🔧 Konflikt automatisch gelöst: {successful_resolution.strategy_used.value}") + else: + return jsonify({ + "error": "Konflikte können nicht automatisch gelöst werden", + "conflicts": [c.description for c in conflicts], + "requires_manual_intervention": True + }), 409 + elif conflicts and not auto_resolve: + return jsonify({ + "error": "Konflikte erkannt - automatische Lösung deaktiviert", + "conflicts": [c.description for c in conflicts], + "suggestions": [s for c in conflicts for s in c.suggested_solutions] + }), 409 + + # Finalen Drucker ermitteln + if not final_job_data.get('printer_id'): + # Intelligente Druckerzuweisung + printer_id = get_smart_printer_assignment( + start_date=final_job_data['start_time'], + end_date=final_job_data['end_time'], + priority=priority, + db_session=db_session + ) + + if not printer_id: + return jsonify({ + "error": "Keine verfügbaren Drucker für den gewünschten Zeitraum gefunden" + }), 409 + + final_job_data['printer_id'] = printer_id + resolution_messages.append("Drucker automatisch zugewiesen") + + # Drucker validieren + printer = db_session.query(Printer).filter_by(id=final_job_data['printer_id']).first() + if not printer: + return jsonify({"error": "Zugewiesener Drucker nicht gefunden"}), 404 + + if not printer.active: + return jsonify({"error": f"Drucker '{printer.name}' ist nicht aktiv"}), 400 + + # Job erstellen + job = Job( + name=title, + description=description, + user_id=current_user.id, + printer_id=final_job_data['printer_id'], + start_at=final_job_data['start_time'], + end_at=final_job_data['end_time'], + status="scheduled", + duration_minutes=duration_minutes, + owner_id=current_user.id + ) + + db_session.add(job) + db_session.commit() + + assignment_type = "automatisch mit Konfliktlösung" if conflicts else "automatisch" + logger.info(f"✅ Job mit Konfliktlösung erstellt: ID {job.id}, " + f"Drucker: {printer.name} ({assignment_type})") + + return jsonify({ + "success": True, + "job": { + "id": job.id, + "title": job.name, + "start": job.start_at.isoformat(), + "end": job.end_at.isoformat(), + "status": job.status + }, + "printer": { + "id": printer.id, + "name": printer.name, + "location": printer.location + }, + "conflict_resolution": { + "conflicts_detected": len(conflicts), + "conflicts_resolved": len([r for r in (resolutions if conflicts else []) if r.success]), + "messages": resolution_messages, + "assignment_type": assignment_type + } + }) + + except Exception as e: + logger.error(f"❌ Fehler bei Konfliktlösung und Job-Erstellung: {str(e)}", exc_info=True) + return jsonify({"error": "Fehler bei der Verarbeitung"}), 500 + +@calendar_blueprint.route('/api/calendar/printer-availability', methods=['GET']) +@login_required +def api_printer_availability(): + """ + Zeigt detaillierte Verfügbarkeit aller Drucker für einen Zeitraum an. + """ + try: + # Parameter extrahieren + start_str = request.args.get('start') + end_str = request.args.get('end') + + if not start_str or not end_str: + return jsonify({"error": "Start- und Endzeit erforderlich"}), 400 + + try: + start_time = datetime.fromisoformat(start_str) + end_time = datetime.fromisoformat(end_str) + except ValueError: + return jsonify({"error": "Ungültiges Datumsformat"}), 400 + + with get_cached_session() as db_session: + # Alle aktiven Drucker laden + printers = db_session.query(Printer).filter_by(active=True).all() + + availability_info = [] + + for printer in printers: + # Jobs im Zeitraum finden + jobs_in_period = db_session.query(Job).filter( + Job.printer_id == printer.id, + Job.status.in_(["scheduled", "running"]), + or_( + and_(Job.start_at >= start_time, Job.start_at < end_time), + and_(Job.end_at > start_time, Job.end_at <= end_time), + and_(Job.start_at <= start_time, Job.end_at >= end_time) + ) + ).all() + + # Auslastung der letzten 24 Stunden + last_24h = datetime.now() - timedelta(hours=24) + recent_jobs = db_session.query(Job).filter( + Job.printer_id == printer.id, + Job.start_at >= last_24h, + Job.status.in_(["scheduled", "running", "finished"]) + ).count() + + # Verfügbarkeits-Status bestimmen + is_available = len(jobs_in_period) == 0 + + if recent_jobs == 0: + availability_status = "optimal" + availability_icon = "🟢" + elif recent_jobs <= 2: + availability_status = "gut" + availability_icon = "🟡" + elif recent_jobs <= 5: + availability_status = "mäßig" + availability_icon = "🟠" + else: + availability_status = "hoch_belegt" + availability_icon = "🔴" + + if not printer.active: + availability_status = "offline" + availability_icon = "⚫" + + # Nächste freie Zeitfenster finden + next_free_slots = [] + if not is_available: + # Einfache Implementierung: Zeitfenster nach bestehenden Jobs + last_job_end = max([job.end_at for job in jobs_in_period]) + next_free_slots.append({ + 'start': last_job_end.isoformat(), + 'description': f"Frei ab {last_job_end.strftime('%H:%M')}" + }) + + # Belegte Zeitfenster + occupied_slots = [] + for job in jobs_in_period: + occupied_slots.append({ + 'job_id': job.id, + 'job_name': job.name, + 'start': job.start_at.isoformat() if job.start_at else None, + 'end': job.end_at.isoformat() if job.end_at else None, + 'user_name': job.user.name if job.user else "Unbekannt", + 'status': job.status + }) + + printer_info = { + 'printer_id': printer.id, + 'printer_name': printer.name, + 'location': printer.location, + 'model': printer.model, + 'is_available': is_available, + 'availability_status': availability_status, + 'availability_icon': availability_icon, + 'recent_jobs_24h': recent_jobs, + 'jobs_in_period': len(jobs_in_period), + 'occupied_slots': occupied_slots, + 'next_free_slots': next_free_slots, + 'status_description': { + 'optimal': 'Keine kürzlichen Jobs, sofort verfügbar', + 'gut': 'Wenige kürzliche Jobs, gute Verfügbarkeit', + 'mäßig': 'Moderate Auslastung', + 'hoch_belegt': 'Hohe Auslastung, möglicherweise Wartezeit', + 'offline': 'Drucker offline oder nicht aktiv' + }.get(availability_status, availability_status) + } + + availability_info.append(printer_info) + + # Nach Verfügbarkeit sortieren (beste zuerst) + availability_info.sort(key=lambda x: ( + not x['is_available'], # Verfügbare zuerst + x['recent_jobs_24h'], # Dann nach geringster Auslastung + x['printer_name'] # Dann alphabetisch + )) + + # Zusammenfassung erstellen + total_printers = len(availability_info) + available_printers = len([p for p in availability_info if p['is_available']]) + optimal_printers = len([p for p in availability_info if p['availability_status'] == 'optimal']) + + summary = { + 'total_printers': total_printers, + 'available_printers': available_printers, + 'optimal_printers': optimal_printers, + 'availability_rate': round((available_printers / total_printers * 100) if total_printers > 0 else 0, 1), + 'period': { + 'start': start_time.isoformat(), + 'end': end_time.isoformat(), + 'duration_hours': round((end_time - start_time).total_seconds() / 3600, 1) + } + } + + logger.info(f"📊 Verfügbarkeitsabfrage: {available_printers}/{total_printers} Drucker verfügbar") + + return jsonify({ + 'summary': summary, + 'printers': availability_info + }) + + except Exception as e: + logger.error(f"❌ Fehler bei Verfügbarkeitsabfrage: {str(e)}", exc_info=True) + return jsonify({"error": "Fehler bei der Verfügbarkeitsanalyse"}), 500 \ No newline at end of file diff --git a/backend/docs/DRUCKERKONFLIKT_MANAGEMENT.md b/backend/docs/DRUCKERKONFLIKT_MANAGEMENT.md new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/backend/docs/DRUCKERKONFLIKT_MANAGEMENT.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/docs/STECKDOSEN_MONITORING.md b/backend/docs/STECKDOSEN_MONITORING.md new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/backend/docs/STECKDOSEN_MONITORING.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/templates/admin_plug_monitoring.html b/backend/templates/admin_plug_monitoring.html new file mode 100644 index 00000000..96d179ca --- /dev/null +++ b/backend/templates/admin_plug_monitoring.html @@ -0,0 +1,585 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} - Mercedes-Benz MYP{% endblock %} + +{% block content %} +
+ +
+
+
+ + + + +
+
+

+ 🔌 + {{ page_title }} +

+

+ Übersicht aller Steckdosen-Statusänderungen und Smart Plug Monitoring-Daten +

+
+
+ + +
+
+
+
+
+ + +
+ +
+
+
+
+ + + +
+
+

Gesamt-Logs

+

{{ stats.total_logs or 0 }}

+
+
+
+ +
+
+
+ + + +
+
+

Ø Antwortzeit

+

+ {% if stats.average_response_time_ms %} + {{ "%.0f"|format(stats.average_response_time_ms) }}ms + {% else %} + -- + {% endif %} +

+
+
+
+ +
+
+
+ + + +
+
+

Fehlerrate

+

{{ "%.1f"|format(stats.error_rate) }}%

+
+
+
+ +
+
+
+ + + +
+
+

Aktive Drucker

+

{{ printers|length }}

+
+
+
+
+ + +
+

Filter

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+

Status-Verteilung (24h)

+
+ {% for status, count in stats.status_distribution.items() %} +
+
+ {% if status == 'connected' %}🔌 + {% elif status == 'disconnected' %}❌ + {% elif status == 'on' %}🟢 + {% elif status == 'off' %}🔴 + {% else %}❓{% endif %} +
+
{{ count }}
+
{{ status }}
+
+ {% endfor %} +
+
+ + +
+
+
+

Steckdosen-Logs

+
+ Lade Daten... + +
+
+
+ + +
+
+ + + + + Lade Steckdosen-Logs... +
+
+ + + + + + +
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/base.html b/backend/templates/base.html index 1d93aa67..2a6fad29 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -714,6 +714,15 @@ Online + {% if current_user.is_authenticated and current_user.is_admin %} +
+ + 🔌 + Steckdosen-Monitoring + +
+ {% endif %} diff --git a/backend/utils/conflict_manager.py b/backend/utils/conflict_manager.py new file mode 100644 index 00000000..364fcaf4 --- /dev/null +++ b/backend/utils/conflict_manager.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +""" +Erweiterte Druckerkonflikt-Management-Engine - MYP Platform + +Dieses Modul behandelt alle Arten von Druckerkonflikten: +- Zeitüberschneidungen +- Ressourcenkonflikte +- Prioritätskonflikte +- Automatische Lösungsfindung +- Benutzerbenachrichtigungen +""" + +import logging +from datetime import datetime, timedelta +from typing import List, Dict, Tuple, Optional, Set +from dataclasses import dataclass +from enum import Enum +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from models import Job, Printer, User +from database.db_manager import get_cached_session + +# Logging setup +logger = logging.getLogger(__name__) + +class ConflictType(Enum): + """Konflikttypen im System""" + TIME_OVERLAP = "zeitüberschneidung" + PRINTER_OFFLINE = "drucker_offline" + RESOURCE_UNAVAILABLE = "ressource_nicht_verfügbar" + PRIORITY_CONFLICT = "prioritätskonflikt" + MAINTENANCE_CONFLICT = "wartungskonflikt" + +class ConflictSeverity(Enum): + """Schweregrade von Konflikten""" + CRITICAL = "kritisch" # Verhindert Job-Ausführung komplett + HIGH = "hoch" # Beeinträchtigt Job-Qualität stark + MEDIUM = "mittel" # Beeinträchtigt Job-Effizienz + LOW = "niedrig" # Geringfügige Beeinträchtigung + INFO = "information" # Nur informativ + +class ResolutionStrategy(Enum): + """Lösungsstrategien für Konflikte""" + AUTO_REASSIGN = "automatische_neuzuweisung" + TIME_SHIFT = "zeitverschiebung" + PRIORITY_PREEMPTION = "prioritäts_verdrängung" + QUEUE_PLACEMENT = "warteschlange" + MANUAL_INTERVENTION = "manuelle_behandlung" + RESOURCE_SUBSTITUTION = "ressourcen_ersatz" + +@dataclass +class ConflictDetails: + """Detaillierte Konfliktinformationen""" + conflict_type: ConflictType + severity: ConflictSeverity + affected_job_id: int + conflicting_job_ids: List[int] + affected_printer_id: Optional[int] + conflict_start: datetime + conflict_end: datetime + description: str + suggested_solutions: List[Dict] + estimated_impact: str + auto_resolvable: bool + +@dataclass +class ConflictResolution: + """Ergebnis einer Konfliktlösung""" + success: bool + strategy_used: ResolutionStrategy + new_printer_id: Optional[int] + new_start_time: Optional[datetime] + new_end_time: Optional[datetime] + affected_jobs: List[int] + user_notification_required: bool + message: str + confidence_score: float + +class ConflictManager: + """Zentrale Konfliktmanagement-Engine""" + + def __init__(self): + self.priority_weights = { + 'urgent': 4, + 'high': 3, + 'normal': 2, + 'low': 1 + } + + self.time_slot_preferences = { + 'night_shift': {'start': 18, 'end': 6, 'bonus': 25}, + 'day_shift': {'start': 8, 'end': 17, 'bonus': 15}, + 'transition': {'start': 6, 'end': 8, 'bonus': 5} + } + + self.conflict_resolution_timeout = 300 # 5 Minuten + + def detect_conflicts(self, job_data: Dict, db_session: Session) -> List[ConflictDetails]: + """ + Erkennt alle möglichen Konflikte für einen geplanten Job + + Args: + job_data: Job-Informationen (printer_id, start_time, end_time, priority) + db_session: Datenbankverbindung + + Returns: + Liste aller erkannten Konflikte + """ + conflicts = [] + + # 1. Zeitüberschneidungs-Konflikte prüfen + time_conflicts = self._detect_time_conflicts(job_data, db_session) + conflicts.extend(time_conflicts) + + # 2. Drucker-Verfügbarkeits-Konflikte prüfen + printer_conflicts = self._detect_printer_conflicts(job_data, db_session) + conflicts.extend(printer_conflicts) + + # 3. Ressourcen-Konflikte prüfen + resource_conflicts = self._detect_resource_conflicts(job_data, db_session) + conflicts.extend(resource_conflicts) + + # 4. Prioritäts-Konflikte prüfen + priority_conflicts = self._detect_priority_conflicts(job_data, db_session) + conflicts.extend(priority_conflicts) + + logger.info(f"🔍 Konfliktanalyse abgeschlossen: {len(conflicts)} Konflikte erkannt") + return conflicts + + def _detect_time_conflicts(self, job_data: Dict, db_session: Session) -> List[ConflictDetails]: + """Erkennt Zeitüberschneidungs-Konflikte""" + conflicts = [] + + printer_id = job_data.get('printer_id') + start_time = job_data.get('start_time') + end_time = job_data.get('end_time') + + if not all([printer_id, start_time, end_time]): + return conflicts + + # Konflikthafte Jobs finden + conflicting_jobs = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled", "running"]), + or_( + and_(Job.start_at >= start_time, Job.start_at < end_time), + and_(Job.end_at > start_time, Job.end_at <= end_time), + and_(Job.start_at <= start_time, Job.end_at >= end_time) + ) + ).all() + + for conflicting_job in conflicting_jobs: + # Konflikt-Schweregrad bestimmen + overlap_duration = self._calculate_overlap_duration( + start_time, end_time, + conflicting_job.start_at, conflicting_job.end_at + ) + + if overlap_duration.total_seconds() > 3600: # > 1 Stunde + severity = ConflictSeverity.CRITICAL + elif overlap_duration.total_seconds() > 1800: # > 30 Minuten + severity = ConflictSeverity.HIGH + else: + severity = ConflictSeverity.MEDIUM + + # Lösungsvorschläge generieren + suggestions = self._generate_time_conflict_solutions( + job_data, conflicting_job, db_session + ) + + conflict = ConflictDetails( + conflict_type=ConflictType.TIME_OVERLAP, + severity=severity, + affected_job_id=job_data.get('job_id', 0), + conflicting_job_ids=[conflicting_job.id], + affected_printer_id=printer_id, + conflict_start=max(start_time, conflicting_job.start_at), + conflict_end=min(end_time, conflicting_job.end_at), + description=f"Zeitüberschneidung mit Job '{conflicting_job.name}' " + f"({overlap_duration.total_seconds()/60:.0f} Minuten)", + suggested_solutions=suggestions, + estimated_impact=f"Verzögerung von {overlap_duration.total_seconds()/60:.0f} Minuten", + auto_resolvable=len(suggestions) > 0 + ) + + conflicts.append(conflict) + + return conflicts + + def _detect_printer_conflicts(self, job_data: Dict, db_session: Session) -> List[ConflictDetails]: + """Erkennt Drucker-Verfügbarkeits-Konflikte""" + conflicts = [] + + printer_id = job_data.get('printer_id') + if not printer_id: + return conflicts + + printer = db_session.query(Printer).filter_by(id=printer_id).first() + if not printer: + conflict = ConflictDetails( + conflict_type=ConflictType.PRINTER_OFFLINE, + severity=ConflictSeverity.CRITICAL, + affected_job_id=job_data.get('job_id', 0), + conflicting_job_ids=[], + affected_printer_id=printer_id, + conflict_start=job_data.get('start_time'), + conflict_end=job_data.get('end_time'), + description=f"Drucker ID {printer_id} existiert nicht", + suggested_solutions=[], + estimated_impact="Job kann nicht ausgeführt werden", + auto_resolvable=False + ) + conflicts.append(conflict) + return conflicts + + # Drucker-Status prüfen + if not printer.active: + suggestions = self._generate_printer_alternative_solutions(job_data, db_session) + + conflict = ConflictDetails( + conflict_type=ConflictType.PRINTER_OFFLINE, + severity=ConflictSeverity.HIGH, + affected_job_id=job_data.get('job_id', 0), + conflicting_job_ids=[], + affected_printer_id=printer_id, + conflict_start=job_data.get('start_time'), + conflict_end=job_data.get('end_time'), + description=f"Drucker '{printer.name}' ist offline oder nicht aktiv", + suggested_solutions=suggestions, + estimated_impact="Automatische Neuzuweisung erforderlich", + auto_resolvable=len(suggestions) > 0 + ) + conflicts.append(conflict) + + return conflicts + + def _detect_resource_conflicts(self, job_data: Dict, db_session: Session) -> List[ConflictDetails]: + """Erkennt Ressourcen-Verfügbarkeits-Konflikte""" + conflicts = [] + + # TODO: Implementierung für Material-, Personal- und andere Ressourcenkonflikte + # Aktuell Platzhalter für zukünftige Erweiterungen + + return conflicts + + def _detect_priority_conflicts(self, job_data: Dict, db_session: Session) -> List[ConflictDetails]: + """Erkennt Prioritäts-basierte Konflikte""" + conflicts = [] + + job_priority = job_data.get('priority', 'normal') + if job_priority not in ['urgent', 'high']: + return conflicts # Nur hohe Prioritäten können andere verdrängen + + printer_id = job_data.get('printer_id') + start_time = job_data.get('start_time') + end_time = job_data.get('end_time') + + if not all([printer_id, start_time, end_time]): + return conflicts + + # Niedrigerprioisierte Jobs im gleichen Zeitraum finden + lower_priority_jobs = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled"]), + or_( + and_(Job.start_at >= start_time, Job.start_at < end_time), + and_(Job.end_at > start_time, Job.end_at <= end_time), + and_(Job.start_at <= start_time, Job.end_at >= end_time) + ) + ).all() + + for existing_job in lower_priority_jobs: + existing_priority = getattr(existing_job, 'priority', 'normal') + existing_weight = self.priority_weights.get(existing_priority, 2) + new_weight = self.priority_weights.get(job_priority, 2) + + if new_weight > existing_weight: + suggestions = self._generate_priority_conflict_solutions( + job_data, existing_job, db_session + ) + + conflict = ConflictDetails( + conflict_type=ConflictType.PRIORITY_CONFLICT, + severity=ConflictSeverity.MEDIUM, + affected_job_id=job_data.get('job_id', 0), + conflicting_job_ids=[existing_job.id], + affected_printer_id=printer_id, + conflict_start=start_time, + conflict_end=end_time, + description=f"Höherpriorer Job verdrängt '{existing_job.name}' " + f"({job_priority} > {existing_priority})", + suggested_solutions=suggestions, + estimated_impact="Umplanung eines bestehenden Jobs erforderlich", + auto_resolvable=True + ) + conflicts.append(conflict) + + return conflicts + + def resolve_conflicts(self, conflicts: List[ConflictDetails], + job_data: Dict, db_session: Session) -> List[ConflictResolution]: + """ + Löst alle erkannten Konflikte automatisch oder semi-automatisch + + Args: + conflicts: Liste der zu lösenden Konflikte + job_data: Job-Informationen + db_session: Datenbankverbindung + + Returns: + Liste der Konfliktlösungen + """ + resolutions = [] + + # Konflikte nach Schweregrad sortieren (kritische zuerst) + sorted_conflicts = sorted(conflicts, + key=lambda c: list(ConflictSeverity).index(c.severity)) + + for conflict in sorted_conflicts: + if conflict.auto_resolvable and conflict.suggested_solutions: + resolution = self._auto_resolve_conflict(conflict, job_data, db_session) + resolutions.append(resolution) + else: + # Manuelle Behandlung erforderlich + resolution = ConflictResolution( + success=False, + strategy_used=ResolutionStrategy.MANUAL_INTERVENTION, + new_printer_id=None, + new_start_time=None, + new_end_time=None, + affected_jobs=[conflict.affected_job_id], + user_notification_required=True, + message=f"Manueller Eingriff erforderlich: {conflict.description}", + confidence_score=0.0 + ) + resolutions.append(resolution) + + logger.info(f"🔧 Konfliktlösung abgeschlossen: {len(resolutions)} Konflikte bearbeitet") + return resolutions + + def _auto_resolve_conflict(self, conflict: ConflictDetails, + job_data: Dict, db_session: Session) -> ConflictResolution: + """Automatische Konfliktlösung""" + + # Beste Lösung aus Vorschlägen wählen + best_solution = max(conflict.suggested_solutions, + key=lambda s: s.get('confidence', 0)) + + strategy = ResolutionStrategy(best_solution['strategy']) + + try: + if strategy == ResolutionStrategy.AUTO_REASSIGN: + return self._execute_auto_reassignment(conflict, best_solution, job_data, db_session) + elif strategy == ResolutionStrategy.TIME_SHIFT: + return self._execute_time_shift(conflict, best_solution, job_data, db_session) + elif strategy == ResolutionStrategy.PRIORITY_PREEMPTION: + return self._execute_priority_preemption(conflict, best_solution, job_data, db_session) + else: + raise ValueError(f"Unbekannte Strategie: {strategy}") + + except Exception as e: + logger.error(f"❌ Fehler bei automatischer Konfliktlösung: {str(e)}") + return ConflictResolution( + success=False, + strategy_used=strategy, + new_printer_id=None, + new_start_time=None, + new_end_time=None, + affected_jobs=[conflict.affected_job_id], + user_notification_required=True, + message=f"Automatische Lösung fehlgeschlagen: {str(e)}", + confidence_score=0.0 + ) + + def _execute_auto_reassignment(self, conflict: ConflictDetails, solution: Dict, + job_data: Dict, db_session: Session) -> ConflictResolution: + """Führt automatische Druckerzuweisung durch""" + + new_printer_id = solution['new_printer_id'] + printer = db_session.query(Printer).filter_by(id=new_printer_id).first() + + if not printer or not printer.active: + return ConflictResolution( + success=False, + strategy_used=ResolutionStrategy.AUTO_REASSIGN, + new_printer_id=None, + new_start_time=None, + new_end_time=None, + affected_jobs=[conflict.affected_job_id], + user_notification_required=True, + message="Alternativer Drucker nicht mehr verfügbar", + confidence_score=0.0 + ) + + return ConflictResolution( + success=True, + strategy_used=ResolutionStrategy.AUTO_REASSIGN, + new_printer_id=new_printer_id, + new_start_time=job_data.get('start_time'), + new_end_time=job_data.get('end_time'), + affected_jobs=[conflict.affected_job_id], + user_notification_required=True, + message=f"Job automatisch zu Drucker '{printer.name}' verschoben", + confidence_score=solution.get('confidence', 0.8) + ) + + def _execute_time_shift(self, conflict: ConflictDetails, solution: Dict, + job_data: Dict, db_session: Session) -> ConflictResolution: + """Führt Zeitverschiebung durch""" + + new_start = solution['new_start_time'] + new_end = solution['new_end_time'] + + return ConflictResolution( + success=True, + strategy_used=ResolutionStrategy.TIME_SHIFT, + new_printer_id=job_data.get('printer_id'), + new_start_time=new_start, + new_end_time=new_end, + affected_jobs=[conflict.affected_job_id], + user_notification_required=True, + message=f"Job zeitlich verschoben: {new_start.strftime('%H:%M')} - {new_end.strftime('%H:%M')}", + confidence_score=solution.get('confidence', 0.7) + ) + + def _execute_priority_preemption(self, conflict: ConflictDetails, solution: Dict, + job_data: Dict, db_session: Session) -> ConflictResolution: + """Führt Prioritätsverdrängung durch""" + + # Bestehenden Job umplanen + conflicting_job_id = conflict.conflicting_job_ids[0] + affected_jobs = [conflict.affected_job_id, conflicting_job_id] + + return ConflictResolution( + success=True, + strategy_used=ResolutionStrategy.PRIORITY_PREEMPTION, + new_printer_id=job_data.get('printer_id'), + new_start_time=job_data.get('start_time'), + new_end_time=job_data.get('end_time'), + affected_jobs=affected_jobs, + user_notification_required=True, + message=f"Höherpriorer Job übernimmt Zeitslot, bestehender Job wird umgeplant", + confidence_score=solution.get('confidence', 0.9) + ) + + # Hilfsmethoden für Lösungsvorschläge + + def _generate_time_conflict_solutions(self, job_data: Dict, + conflicting_job: Job, db_session: Session) -> List[Dict]: + """Generiert Lösungsvorschläge für Zeitkonflikte""" + solutions = [] + + # 1. Alternative Drucker vorschlagen + alternative_printers = self._find_alternative_printers(job_data, db_session) + for printer_id, confidence in alternative_printers: + printer = db_session.query(Printer).filter_by(id=printer_id).first() + solutions.append({ + 'strategy': ResolutionStrategy.AUTO_REASSIGN.value, + 'new_printer_id': printer_id, + 'printer_name': printer.name if printer else f"Drucker {printer_id}", + 'confidence': confidence, + 'description': f"Automatische Umzuweisung zu {printer.name if printer else f'Drucker {printer_id}'}" + }) + + # 2. Zeitverschiebung vorschlagen + time_alternatives = self._find_alternative_time_slots(job_data, db_session) + for start_time, end_time, confidence in time_alternatives: + solutions.append({ + 'strategy': ResolutionStrategy.TIME_SHIFT.value, + 'new_start_time': start_time, + 'new_end_time': end_time, + 'confidence': confidence, + 'description': f"Zeitverschiebung: {start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" + }) + + return solutions + + def _generate_printer_alternative_solutions(self, job_data: Dict, db_session: Session) -> List[Dict]: + """Generiert Lösungsvorschläge für Drucker-Ausfälle""" + solutions = [] + + alternative_printers = self._find_alternative_printers(job_data, db_session) + for printer_id, confidence in alternative_printers: + printer = db_session.query(Printer).filter_by(id=printer_id).first() + solutions.append({ + 'strategy': ResolutionStrategy.AUTO_REASSIGN.value, + 'new_printer_id': printer_id, + 'printer_name': printer.name if printer else f"Drucker {printer_id}", + 'confidence': confidence, + 'description': f"Automatische Neuzuweisung zu {printer.name if printer else f'Drucker {printer_id}'}" + }) + + return solutions + + def _generate_priority_conflict_solutions(self, job_data: Dict, + existing_job: Job, db_session: Session) -> List[Dict]: + """Generiert Lösungsvorschläge für Prioritätskonflikte""" + solutions = [] + + # Bestehenden Job umplanen + alternative_slots = self._find_alternative_time_slots({ + 'printer_id': existing_job.printer_id, + 'start_time': existing_job.start_at, + 'end_time': existing_job.end_at, + 'duration_minutes': existing_job.duration_minutes + }, db_session) + + if alternative_slots: + start_time, end_time, confidence = alternative_slots[0] + solutions.append({ + 'strategy': ResolutionStrategy.PRIORITY_PREEMPTION.value, + 'conflicting_job_new_start': start_time, + 'conflicting_job_new_end': end_time, + 'confidence': confidence, + 'description': f"Bestehenden Job zu {start_time.strftime('%H:%M')} verschieben" + }) + + return solutions + + def _find_alternative_printers(self, job_data: Dict, db_session: Session) -> List[Tuple[int, float]]: + """Findet alternative Drucker mit Confidence-Score""" + from blueprints.calendar import get_smart_printer_assignment + + alternatives = [] + start_time = job_data.get('start_time') + end_time = job_data.get('end_time') + priority = job_data.get('priority', 'normal') + + # Smart Assignment nutzen + recommended_printer_id = get_smart_printer_assignment( + start_date=start_time, + end_date=end_time, + priority=priority, + db_session=db_session + ) + + if recommended_printer_id: + alternatives.append((recommended_printer_id, 0.9)) + + # Weitere verfügbare Drucker mit niedrigerer Confidence + available_printers = db_session.query(Printer).filter( + Printer.active == True, + Printer.id != job_data.get('printer_id'), + Printer.id != recommended_printer_id + ).all() + + for printer in available_printers[:3]: # Top 3 Alternativen + # Einfache Verfügbarkeitsprüfung + conflicts = db_session.query(Job).filter( + Job.printer_id == printer.id, + Job.status.in_(["scheduled", "running"]), + or_( + and_(Job.start_at >= start_time, Job.start_at < end_time), + and_(Job.end_at > start_time, Job.end_at <= end_time), + and_(Job.start_at <= start_time, Job.end_at >= end_time) + ) + ).count() + + if conflicts == 0: + alternatives.append((printer.id, 0.6)) # Niedrigere Confidence + + return alternatives + + def _find_alternative_time_slots(self, job_data: Dict, db_session: Session) -> List[Tuple[datetime, datetime, float]]: + """Findet alternative Zeitfenster""" + alternatives = [] + + printer_id = job_data.get('printer_id') + original_start = job_data.get('start_time') + duration_minutes = job_data.get('duration_minutes') + + if not all([printer_id, original_start, duration_minutes]): + return alternatives + + duration = timedelta(minutes=duration_minutes) + + # Zeitfenster um ursprünglichen Termin herum testen + test_intervals = [ + timedelta(hours=1), # 1 Stunde später + timedelta(hours=2), # 2 Stunden später + timedelta(hours=-1), # 1 Stunde früher + timedelta(hours=3), # 3 Stunden später + timedelta(hours=-2), # 2 Stunden früher + ] + + for interval in test_intervals: + new_start = original_start + interval + new_end = new_start + duration + + # Verfügbarkeit prüfen + conflicts = db_session.query(Job).filter( + Job.printer_id == printer_id, + Job.status.in_(["scheduled", "running"]), + or_( + and_(Job.start_at >= new_start, Job.start_at < new_end), + and_(Job.end_at > new_start, Job.end_at <= new_end), + and_(Job.start_at <= new_start, Job.end_at >= new_end) + ) + ).count() + + if conflicts == 0: + # Confidence basierend auf Zeitnähe zum Original + time_diff_hours = abs(interval.total_seconds() / 3600) + confidence = max(0.3, 1.0 - (time_diff_hours * 0.1)) + alternatives.append((new_start, new_end, confidence)) + + if len(alternatives) >= 3: # Maximal 3 Alternativen + break + + return alternatives + + def _calculate_overlap_duration(self, start1: datetime, end1: datetime, + start2: datetime, end2: datetime) -> timedelta: + """Berechnet Überschneidungsdauer zwischen zwei Zeiträumen""" + overlap_start = max(start1, start2) + overlap_end = min(end1, end2) + + if overlap_start < overlap_end: + return overlap_end - overlap_start + else: + return timedelta(0) + +# Globale Instanz für einfache Nutzung +conflict_manager = ConflictManager() \ No newline at end of file diff --git a/backend/utils/printer_monitor.py b/backend/utils/printer_monitor.py index d0abb0fd..065dda1f 100644 --- a/backend/utils/printer_monitor.py +++ b/backend/utils/printer_monitor.py @@ -15,7 +15,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session import os -from models import get_db_session, Printer +from models import get_db_session, Printer, PlugStatusLog from utils.logging_config import get_logger from config.settings import PRINTERS, TAPO_USERNAME, TAPO_PASSWORD, DEFAULT_TAPO_IPS, TAPO_AUTO_DISCOVERY @@ -102,7 +102,8 @@ class PrinterMonitor: success = self._turn_outlet_off( printer.plug_ip, printer.plug_username, - printer.plug_password + printer.plug_password, + printer_id=printer.id ) results[printer.name] = success @@ -138,7 +139,7 @@ class PrinterMonitor: return results - def _turn_outlet_off(self, ip_address: str, username: str, password: str, timeout: int = 5) -> bool: + def _turn_outlet_off(self, ip_address: str, username: str, password: str, timeout: int = 5, printer_id: int = None) -> bool: """ Schaltet eine TP-Link Tapo P110-Steckdose aus. @@ -147,19 +148,35 @@ class PrinterMonitor: username: Benutzername für die Steckdose (wird überschrieben) password: Passwort für die Steckdose (wird überschrieben) timeout: Timeout in Sekunden (wird ignoriert, da PyP100 eigenes Timeout hat) + printer_id: ID des zugehörigen Druckers (für Logging) Returns: bool: True wenn erfolgreich ausgeschaltet """ if not TAPO_AVAILABLE: monitor_logger.error("⚠️ PyP100-Modul nicht verfügbar - kann Tapo-Steckdose nicht schalten") + # Logging: Fehlgeschlagener Versuch + if printer_id: + try: + PlugStatusLog.log_status_change( + printer_id=printer_id, + status="disconnected", + source="system", + ip_address=ip_address, + error_message="PyP100-Modul nicht verfügbar", + notes="Startup-Initialisierung fehlgeschlagen" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") return False # IMMER globale Anmeldedaten verwenden (da diese funktionieren) username = TAPO_USERNAME password = TAPO_PASSWORD monitor_logger.debug(f"🔧 Verwende globale Tapo-Anmeldedaten für {ip_address}") - + + start_time = time.time() + try: # TP-Link Tapo P100 Verbindung herstellen (P100 statt P110) from PyP100 import PyP100 @@ -169,11 +186,45 @@ class PrinterMonitor: # Steckdose ausschalten p100.turnOff() + + response_time = int((time.time() - start_time) * 1000) # in Millisekunden monitor_logger.debug(f"✅ Tapo-Steckdose {ip_address} erfolgreich ausgeschaltet") + + # Logging: Erfolgreich ausgeschaltet + if printer_id: + try: + PlugStatusLog.log_status_change( + printer_id=printer_id, + status="off", + source="system", + ip_address=ip_address, + response_time_ms=response_time, + notes="Startup-Initialisierung: Steckdose ausgeschaltet" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") + return True except Exception as e: + response_time = int((time.time() - start_time) * 1000) # in Millisekunden monitor_logger.debug(f"⚠️ Fehler beim Ausschalten der Tapo-Steckdose {ip_address}: {str(e)}") + + # Logging: Fehlgeschlagener Versuch + if printer_id: + try: + PlugStatusLog.log_status_change( + printer_id=printer_id, + status="disconnected", + source="system", + ip_address=ip_address, + response_time_ms=response_time, + error_message=str(e), + notes="Startup-Initialisierung fehlgeschlagen" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") + return False def get_live_printer_status(self, use_session_cache: bool = True) -> Dict[int, Dict]: @@ -337,7 +388,8 @@ class PrinterMonitor: printer.plug_ip, printer.plug_username, printer.plug_password, - timeout + timeout, + printer_id=printer.id ) status_info["outlet_reachable"] = outlet_reachable @@ -432,7 +484,7 @@ class PrinterMonitor: monitor_logger.debug(f"❌ Fehler beim Verbindungstest zu {ip_address}: {str(e)}") return False - def _check_outlet_status(self, ip_address: str, username: str, password: str, timeout: int = 5) -> Tuple[bool, str]: + def _check_outlet_status(self, ip_address: str, username: str, password: str, timeout: int = 5, printer_id: int = None) -> Tuple[bool, str]: """ Überprüft den Status einer TP-Link Tapo P110-Steckdose. @@ -441,19 +493,37 @@ class PrinterMonitor: username: Benutzername für die Steckdose password: Passwort für die Steckdose timeout: Timeout in Sekunden (wird ignoriert, da PyP100 eigenes Timeout hat) + printer_id: ID des zugehörigen Druckers (für Logging) Returns: Tuple[bool, str]: (Erreichbar, Status) - Status: "on", "off", "unknown" """ if not TAPO_AVAILABLE: monitor_logger.debug("⚠️ PyP100-Modul nicht verfügbar - kann Tapo-Steckdosen-Status nicht abfragen") + + # Logging: Modul nicht verfügbar + if printer_id: + try: + PlugStatusLog.log_status_change( + printer_id=printer_id, + status="disconnected", + source="system", + ip_address=ip_address, + error_message="PyP100-Modul nicht verfügbar", + notes="Status-Check fehlgeschlagen" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") + return False, "unknown" # IMMER globale Anmeldedaten verwenden (da diese funktionieren) username = TAPO_USERNAME password = TAPO_PASSWORD monitor_logger.debug(f"🔧 Verwende globale Tapo-Anmeldedaten für {ip_address}") - + + start_time = time.time() + try: # TP-Link Tapo P100 Verbindung herstellen (P100 statt P110) from PyP100 import PyP100 @@ -468,11 +538,70 @@ class PrinterMonitor: device_on = device_info.get('device_on', False) status = "on" if device_on else "off" + response_time = int((time.time() - start_time) * 1000) # in Millisekunden + monitor_logger.debug(f"✅ Tapo-Steckdose {ip_address}: Status = {status}") + + # Logging: Erfolgreicher Status-Check + if printer_id: + try: + # Hole zusätzliche Geräteinformationen falls verfügbar + power_consumption = None + voltage = None + current = None + firmware_version = None + + try: + # Versuche Energiedaten zu holen (P110 spezifisch) + energy_usage = p100.getEnergyUsage() + if energy_usage: + power_consumption = energy_usage.get('current_power', None) + voltage = energy_usage.get('voltage', None) + current = energy_usage.get('current', None) + except: + pass # P100 unterstützt keine Energiedaten + + try: + firmware_version = device_info.get('fw_ver', None) + except: + pass + + PlugStatusLog.log_status_change( + printer_id=printer_id, + status=status, + source="system", + ip_address=ip_address, + power_consumption=power_consumption, + voltage=voltage, + current=current, + response_time_ms=response_time, + firmware_version=firmware_version, + notes="Automatischer Status-Check" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") + return True, status except Exception as e: + response_time = int((time.time() - start_time) * 1000) # in Millisekunden monitor_logger.debug(f"⚠️ Fehler bei Tapo-Steckdosen-Status-Check {ip_address}: {str(e)}") + + # Logging: Fehlgeschlagener Status-Check + if printer_id: + try: + PlugStatusLog.log_status_change( + printer_id=printer_id, + status="disconnected", + source="system", + ip_address=ip_address, + response_time_ms=response_time, + error_message=str(e), + notes="Status-Check fehlgeschlagen" + ) + except Exception as log_error: + monitor_logger.warning(f"Fehler beim Loggen des Steckdosen-Status: {log_error}") + return False, "unknown" def clear_all_caches(self):