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...
+
+
+
+
+
+
+
+
+
+ Status |
+ Drucker |
+ Zeitstempel |
+ Quelle |
+ Details |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine Logs gefunden
+
Für die ausgewählten Filter wurden keine Steckdosen-Logs gefunden.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Alte Logs löschen
+
+
+ Wie viele Tage alte Logs sollen gelöscht werden?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+ {% 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):