🎉 Refactor & Update Backend Files, Documentation 📚
This commit is contained in:
parent
193164964e
commit
b2bdc2d123
1
backend/README.md
Normal file
1
backend/README.md
Normal file
@ -0,0 +1 @@
|
||||
|
2777
backend/app.py
2777
backend/app.py
File diff suppressed because it is too large
Load Diff
@ -96,6 +96,12 @@ def guest_request_form():
|
||||
)
|
||||
|
||||
db_session.add(guest_request)
|
||||
db_session.flush() # Um ID zu erhalten
|
||||
|
||||
# OTP-Code sofort generieren für Status-Abfrage
|
||||
otp_code = guest_request.generate_otp()
|
||||
guest_request.otp_expires_at = datetime.now() + timedelta(hours=72) # 72h gültig
|
||||
|
||||
db_session.commit()
|
||||
|
||||
# Benachrichtigung für Genehmiger erstellen
|
||||
@ -109,10 +115,10 @@ def guest_request_form():
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Neue Gastanfrage erstellt: ID {guest_request.id}, Name: {name}")
|
||||
logger.info(f"Neue Gastanfrage erstellt: ID {guest_request.id}, Name: {name}, OTP generiert")
|
||||
flash("Ihr Antrag wurde erfolgreich eingereicht!", "success")
|
||||
|
||||
# Weiterleitung zur Status-Seite
|
||||
# Weiterleitung zur Status-Seite mit OTP-Code-Info
|
||||
return redirect(url_for('guest.guest_request_status', request_id=guest_request.id))
|
||||
|
||||
except Exception as e:
|
||||
@ -295,6 +301,12 @@ def api_create_guest_request():
|
||||
)
|
||||
|
||||
db_session.add(guest_request)
|
||||
db_session.flush() # Um ID zu erhalten
|
||||
|
||||
# OTP-Code sofort generieren für Status-Abfrage
|
||||
otp_code = guest_request.generate_otp()
|
||||
guest_request.otp_expires_at = datetime.now() + timedelta(hours=72) # 72h gültig
|
||||
|
||||
db_session.commit()
|
||||
|
||||
# Benachrichtigung für Genehmiger erstellen
|
||||
@ -308,12 +320,14 @@ def api_create_guest_request():
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Neue Gastanfrage erstellt: ID {guest_request.id}, Name: {name}")
|
||||
logger.info(f"Neue Gastanfrage erstellt: ID {guest_request.id}, Name: {name}, OTP generiert")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"request_id": guest_request.id,
|
||||
"status": guest_request.status,
|
||||
"otp_code": otp_code, # Code wird nur bei Erstellung zurückgegeben
|
||||
"status_check_url": url_for('guest.guest_status_check_page', _external=True),
|
||||
"redirect_url": url_for('guest.guest_request_status', request_id=guest_request.id)
|
||||
})
|
||||
|
||||
@ -852,4 +866,126 @@ def api_deny_request(request_id):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Ablehnen der Gastanfrage: {str(e)}")
|
||||
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
|
||||
return jsonify({"error": "Fehler beim Verarbeiten der Anfrage"}), 500
|
||||
|
||||
@guest_blueprint.route('/api/guest/status', methods=['POST'])
|
||||
def api_guest_status_by_otp():
|
||||
"""
|
||||
Öffentliche Route für Gäste um ihren Auftragsstatus mit OTP-Code zu prüfen.
|
||||
Keine Authentifizierung erforderlich.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Keine Daten empfangen'
|
||||
}), 400
|
||||
|
||||
otp_code = data.get('otp_code', '').strip()
|
||||
email = data.get('email', '').strip() # Optional für zusätzliche Verifikation
|
||||
|
||||
if not otp_code:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'OTP-Code ist erforderlich'
|
||||
}), 400
|
||||
|
||||
with get_cached_session() as db_session:
|
||||
# Alle Gastaufträge mit OTP-Codes finden
|
||||
guest_requests = db_session.query(GuestRequest).filter(
|
||||
GuestRequest.otp_code.isnot(None)
|
||||
).all()
|
||||
|
||||
found_request = None
|
||||
for request_obj in guest_requests:
|
||||
if request_obj.verify_otp(otp_code):
|
||||
# Zusätzliche E-Mail-Verifikation falls angegeben
|
||||
if email and request_obj.email and request_obj.email.lower() != email.lower():
|
||||
continue
|
||||
found_request = request_obj
|
||||
break
|
||||
|
||||
if not found_request:
|
||||
logger.warning(f"Ungültiger OTP-Code für Gast-Status-Abfrage: {otp_code[:4]}****")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Ungültiger Code oder E-Mail-Adresse'
|
||||
}), 404
|
||||
|
||||
# Status-Informationen für den Gast zusammenstellen
|
||||
status_info = {
|
||||
'id': found_request.id,
|
||||
'name': found_request.name,
|
||||
'file_name': found_request.file_name,
|
||||
'status': found_request.status,
|
||||
'created_at': found_request.created_at.isoformat() if found_request.created_at else None,
|
||||
'updated_at': found_request.updated_at.isoformat() if found_request.updated_at else None,
|
||||
'duration_min': found_request.duration_min,
|
||||
'reason': found_request.reason
|
||||
}
|
||||
|
||||
# Status-spezifische Informationen hinzufügen
|
||||
if found_request.status == 'approved':
|
||||
status_info.update({
|
||||
'approved_at': found_request.approved_at.isoformat() if found_request.approved_at else None,
|
||||
'approval_notes': found_request.approval_notes,
|
||||
'message': 'Ihr Auftrag wurde genehmigt! Sie können mit dem Drucken beginnen.',
|
||||
'can_start_job': found_request.otp_used_at is None # Noch nicht verwendet
|
||||
})
|
||||
|
||||
# Job-Informationen hinzufügen falls vorhanden
|
||||
if found_request.job_id:
|
||||
job = db_session.query(Job).options(joinedload(Job.printer)).filter_by(id=found_request.job_id).first()
|
||||
if job:
|
||||
status_info['job'] = {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'status': job.status,
|
||||
'start_at': job.start_at.isoformat() if job.start_at else None,
|
||||
'end_at': job.end_at.isoformat() if job.end_at else None,
|
||||
'printer_name': job.printer.name if job.printer else None
|
||||
}
|
||||
|
||||
elif found_request.status == 'rejected':
|
||||
status_info.update({
|
||||
'rejected_at': found_request.rejected_at.isoformat() if found_request.rejected_at else None,
|
||||
'rejection_reason': found_request.rejection_reason,
|
||||
'message': 'Ihr Auftrag wurde leider abgelehnt.'
|
||||
})
|
||||
|
||||
elif found_request.status == 'pending':
|
||||
# Berechne wie lange der Auftrag schon wartet
|
||||
if found_request.created_at:
|
||||
waiting_time = datetime.now() - found_request.created_at
|
||||
hours_waiting = int(waiting_time.total_seconds() / 3600)
|
||||
status_info.update({
|
||||
'hours_waiting': hours_waiting,
|
||||
'message': f'Ihr Auftrag wird bearbeitet. Wartezeit: {hours_waiting} Stunden.'
|
||||
})
|
||||
else:
|
||||
status_info['message'] = 'Ihr Auftrag wird bearbeitet.'
|
||||
|
||||
# OTP als verwendet markieren (da erfolgreich abgefragt)
|
||||
db_session.commit()
|
||||
|
||||
logger.info(f"Gast-Status-Abfrage erfolgreich für Request {found_request.id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'request': status_info
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Gast-Status-Abfrage: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Fehler beim Abrufen des Status'
|
||||
}), 500
|
||||
|
||||
@guest_blueprint.route('/status-check')
|
||||
def guest_status_check_page():
|
||||
"""
|
||||
Öffentliche Seite für Gäste um ihren Auftragsstatus zu prüfen.
|
||||
"""
|
||||
return render_template('guest_status_check.html')
|
@ -19,6 +19,7 @@ from models import Printer, User, Job, get_db_session
|
||||
from utils.logging_config import get_logger, measure_execution_time
|
||||
from utils.permissions import require_permission, Permission, check_permission
|
||||
from utils.printer_monitor import printer_monitor
|
||||
from utils.drag_drop_system import drag_drop_manager
|
||||
|
||||
# Logger initialisieren
|
||||
printers_logger = get_logger("printers")
|
||||
@ -613,4 +614,353 @@ def test_all_sockets_status():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Allgemeiner Fehler: {str(e)}"
|
||||
}), 500
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DRAG & DROP API - JOB-REIHENFOLGE-MANAGEMENT
|
||||
# =============================================================================
|
||||
|
||||
@printers_blueprint.route("/<int:printer_id>/jobs/order", methods=["GET"])
|
||||
@login_required
|
||||
@measure_execution_time(logger=printers_logger, task_name="API-Job-Reihenfolge-Abfrage")
|
||||
def get_job_order(printer_id):
|
||||
"""
|
||||
Holt die aktuelle Job-Reihenfolge für einen Drucker.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
Returns:
|
||||
JSON mit Jobs in der korrekten Reihenfolge
|
||||
"""
|
||||
printers_logger.info(f"📋 Job-Reihenfolge-Abfrage für Drucker {printer_id} von Benutzer {current_user.name}")
|
||||
|
||||
try:
|
||||
# Drucker existiert prüfen
|
||||
db_session = get_db_session()
|
||||
printer = db_session.query(Printer).filter(Printer.id == printer_id).first()
|
||||
|
||||
if not printer:
|
||||
db_session.close()
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Drucker mit ID {printer_id} nicht gefunden"
|
||||
}), 404
|
||||
|
||||
db_session.close()
|
||||
|
||||
# Job-Reihenfolge und Details holen
|
||||
ordered_jobs = drag_drop_manager.get_ordered_jobs_for_printer(printer_id)
|
||||
job_order_ids = drag_drop_manager.get_job_order(printer_id)
|
||||
|
||||
# Job-Details für Response aufbereiten
|
||||
jobs_data = []
|
||||
for job in ordered_jobs:
|
||||
jobs_data.append({
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"description": job.description,
|
||||
"user_name": job.user.name if job.user else "Unbekannt",
|
||||
"user_id": job.user_id,
|
||||
"duration_minutes": job.duration_minutes,
|
||||
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||
"start_at": job.start_at.isoformat() if job.start_at else None,
|
||||
"status": job.status,
|
||||
"file_path": job.file_path
|
||||
})
|
||||
|
||||
printers_logger.info(f"✅ Job-Reihenfolge erfolgreich abgerufen: {len(jobs_data)} Jobs für Drucker {printer.name}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"printer": {
|
||||
"id": printer.id,
|
||||
"name": printer.name,
|
||||
"model": printer.model,
|
||||
"location": printer.location
|
||||
},
|
||||
"jobs": jobs_data,
|
||||
"job_order": job_order_ids,
|
||||
"total_jobs": len(jobs_data),
|
||||
"total_duration_minutes": sum(job.duration_minutes for job in ordered_jobs),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
printers_logger.error(f"❌ Fehler bei Job-Reihenfolge-Abfrage für Drucker {printer_id}: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Fehler beim Laden der Job-Reihenfolge: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@printers_blueprint.route("/<int:printer_id>/jobs/order", methods=["POST"])
|
||||
@login_required
|
||||
@require_permission(Permission.APPROVE_JOBS) # Nur Benutzer mit Job-Genehmigungsrechten können Reihenfolge ändern
|
||||
@measure_execution_time(logger=printers_logger, task_name="API-Job-Reihenfolge-Update")
|
||||
def update_job_order(printer_id):
|
||||
"""
|
||||
Aktualisiert die Job-Reihenfolge für einen Drucker per Drag & Drop.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
JSON-Parameter:
|
||||
- job_ids: Liste der Job-IDs in der gewünschten Reihenfolge
|
||||
|
||||
Returns:
|
||||
JSON mit Bestätigung der Aktualisierung
|
||||
"""
|
||||
printers_logger.info(f"🔄 Job-Reihenfolge-Update für Drucker {printer_id} von Benutzer {current_user.name}")
|
||||
|
||||
# Parameter validieren
|
||||
data = request.get_json()
|
||||
if not data or "job_ids" not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Parameter 'job_ids' fehlt"
|
||||
}), 400
|
||||
|
||||
job_ids = data["job_ids"]
|
||||
if not isinstance(job_ids, list):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Parameter 'job_ids' muss eine Liste sein"
|
||||
}), 400
|
||||
|
||||
if not all(isinstance(job_id, int) for job_id in job_ids):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Alle Job-IDs müssen Zahlen sein"
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Drucker existiert prüfen
|
||||
db_session = get_db_session()
|
||||
printer = db_session.query(Printer).filter(Printer.id == printer_id).first()
|
||||
|
||||
if not printer:
|
||||
db_session.close()
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Drucker mit ID {printer_id} nicht gefunden"
|
||||
}), 404
|
||||
|
||||
# Validierung: Alle Jobs gehören zum Drucker und sind editierbar
|
||||
valid_jobs = db_session.query(Job).filter(
|
||||
Job.id.in_(job_ids),
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).all()
|
||||
|
||||
db_session.close()
|
||||
|
||||
if len(valid_jobs) != len(job_ids):
|
||||
invalid_ids = set(job_ids) - {job.id for job in valid_jobs}
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Ungültige oder nicht editierbare Job-IDs: {list(invalid_ids)}"
|
||||
}), 400
|
||||
|
||||
# Berechtigung prüfen: Benutzer kann nur eigene Jobs oder als Admin alle verschieben
|
||||
if not current_user.is_admin:
|
||||
user_job_ids = {job.id for job in valid_jobs if job.user_id == current_user.id}
|
||||
if user_job_ids != set(job_ids):
|
||||
unauthorized_ids = set(job_ids) - user_job_ids
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Keine Berechtigung für Jobs: {list(unauthorized_ids)}"
|
||||
}), 403
|
||||
|
||||
# Job-Reihenfolge aktualisieren
|
||||
success = drag_drop_manager.update_job_order(printer_id, job_ids)
|
||||
|
||||
if success:
|
||||
# Neue Reihenfolge zur Bestätigung laden
|
||||
updated_order = drag_drop_manager.get_job_order(printer_id)
|
||||
|
||||
printers_logger.info(f"✅ Job-Reihenfolge erfolgreich aktualisiert für Drucker {printer.name}")
|
||||
printers_logger.info(f" Neue Reihenfolge: {job_ids}")
|
||||
printers_logger.info(f" Benutzer: {current_user.name} (ID: {current_user.id})")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Job-Reihenfolge erfolgreich aktualisiert",
|
||||
"printer": {
|
||||
"id": printer.id,
|
||||
"name": printer.name
|
||||
},
|
||||
"old_order": job_ids, # Eingabe des Benutzers
|
||||
"new_order": updated_order, # Bestätigung aus Datenbank
|
||||
"total_jobs": len(job_ids),
|
||||
"updated_by": {
|
||||
"id": current_user.id,
|
||||
"name": current_user.name
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Fehler beim Speichern der Job-Reihenfolge"
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
printers_logger.error(f"❌ Fehler bei Job-Reihenfolge-Update für Drucker {printer_id}: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Unerwarteter Fehler: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@printers_blueprint.route("/<int:printer_id>/jobs/summary", methods=["GET"])
|
||||
@login_required
|
||||
@measure_execution_time(logger=printers_logger, task_name="API-Drucker-Job-Zusammenfassung")
|
||||
def get_printer_job_summary(printer_id):
|
||||
"""
|
||||
Erstellt eine detaillierte Zusammenfassung der Jobs für einen Drucker.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
Returns:
|
||||
JSON mit Zusammenfassung, Statistiken und Zeitschätzungen
|
||||
"""
|
||||
printers_logger.info(f"📊 Drucker-Job-Zusammenfassung für Drucker {printer_id} von Benutzer {current_user.name}")
|
||||
|
||||
try:
|
||||
# Drucker existiert prüfen
|
||||
db_session = get_db_session()
|
||||
printer = db_session.query(Printer).filter(Printer.id == printer_id).first()
|
||||
|
||||
if not printer:
|
||||
db_session.close()
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Drucker mit ID {printer_id} nicht gefunden"
|
||||
}), 404
|
||||
|
||||
db_session.close()
|
||||
|
||||
# Zusammenfassung über Drag-Drop-Manager erstellen
|
||||
summary = drag_drop_manager.get_printer_summary(printer_id)
|
||||
|
||||
printers_logger.info(f"✅ Drucker-Job-Zusammenfassung erfolgreich erstellt für {printer.name}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"printer": {
|
||||
"id": printer.id,
|
||||
"name": printer.name,
|
||||
"model": printer.model,
|
||||
"location": printer.location,
|
||||
"status": printer.status
|
||||
},
|
||||
"summary": summary,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
printers_logger.error(f"❌ Fehler bei Drucker-Job-Zusammenfassung für Drucker {printer_id}: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Fehler beim Erstellen der Zusammenfassung: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@printers_blueprint.route("/jobs/cleanup-orders", methods=["POST"])
|
||||
@login_required
|
||||
@require_permission(Permission.ADMIN)
|
||||
@measure_execution_time(logger=printers_logger, task_name="API-Job-Reihenfolgen-Bereinigung")
|
||||
def cleanup_job_orders():
|
||||
"""
|
||||
Bereinigt ungültige Job-Reihenfolgen (nur für Administratoren).
|
||||
Entfernt Einträge für abgeschlossene oder gelöschte Jobs.
|
||||
|
||||
Returns:
|
||||
JSON mit Bereinigungsergebnis
|
||||
"""
|
||||
printers_logger.info(f"🧹 Job-Reihenfolgen-Bereinigung von Admin {current_user.name}")
|
||||
|
||||
try:
|
||||
# Bereinigung durchführen
|
||||
drag_drop_manager.cleanup_invalid_orders()
|
||||
|
||||
printers_logger.info(f"✅ Job-Reihenfolgen-Bereinigung erfolgreich abgeschlossen")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Job-Reihenfolgen erfolgreich bereinigt",
|
||||
"admin": {
|
||||
"id": current_user.id,
|
||||
"name": current_user.name
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
printers_logger.error(f"❌ Fehler bei Job-Reihenfolgen-Bereinigung: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Fehler bei der Bereinigung: {str(e)}"
|
||||
}), 500
|
||||
|
||||
@printers_blueprint.route("/drag-drop/config", methods=["GET"])
|
||||
@login_required
|
||||
def get_drag_drop_config():
|
||||
"""
|
||||
Liefert die Konfiguration für das Drag & Drop System.
|
||||
|
||||
Returns:
|
||||
JSON mit Drag & Drop Konfiguration und JavaScript/CSS
|
||||
"""
|
||||
printers_logger.info(f"⚙️ Drag-Drop-Konfiguration abgerufen von Benutzer {current_user.name}")
|
||||
|
||||
try:
|
||||
from utils.drag_drop_system import get_drag_drop_javascript, get_drag_drop_css
|
||||
|
||||
# Benutzerberechtigungen prüfen
|
||||
can_reorder_jobs = check_permission(current_user, Permission.APPROVE_JOBS)
|
||||
can_upload_files = check_permission(current_user, Permission.CREATE_JOB)
|
||||
|
||||
config = {
|
||||
"permissions": {
|
||||
"can_reorder_jobs": can_reorder_jobs,
|
||||
"can_upload_files": can_upload_files,
|
||||
"is_admin": current_user.is_admin
|
||||
},
|
||||
"settings": {
|
||||
"max_file_size": 50 * 1024 * 1024, # 50MB
|
||||
"accepted_file_types": ["gcode", "stl", "3mf", "obj"],
|
||||
"auto_upload": False,
|
||||
"show_preview": True,
|
||||
"enable_progress_tracking": True
|
||||
},
|
||||
"endpoints": {
|
||||
"get_job_order": f"/api/printers/{{printer_id}}/jobs/order",
|
||||
"update_job_order": f"/api/printers/{{printer_id}}/jobs/order",
|
||||
"get_summary": f"/api/printers/{{printer_id}}/jobs/summary"
|
||||
},
|
||||
"javascript": get_drag_drop_javascript(),
|
||||
"css": get_drag_drop_css()
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"config": config,
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"name": current_user.name,
|
||||
"role": current_user.role
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
printers_logger.error(f"❌ Fehler bei Drag-Drop-Konfiguration: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Fehler beim Laden der Konfiguration: {str(e)}"
|
||||
}), 500
|
||||
|
||||
# =============================================================================
|
||||
# ENDE DRAG & DROP API
|
||||
# =============================================================================
|
1
backend/docs/DRAG_DROP_IMPLEMENTATION.md
Normal file
1
backend/docs/DRAG_DROP_IMPLEMENTATION.md
Normal file
@ -0,0 +1 @@
|
||||
|
1
backend/docs/STECKDOSEN_TEST_DOKUMENTATION.md
Normal file
1
backend/docs/STECKDOSEN_TEST_DOKUMENTATION.md
Normal file
@ -0,0 +1 @@
|
||||
|
@ -33,7 +33,7 @@ _cache_lock = threading.Lock()
|
||||
_cache_ttl = {} # Time-to-live für Cache-Einträge
|
||||
|
||||
# Alle exportierten Modelle
|
||||
__all__ = ['User', 'Printer', 'Job', 'Stats', 'SystemLog', 'Base', 'GuestRequest', 'UserPermission', 'Notification', 'init_db', 'init_database', 'create_initial_admin', 'get_db_session', 'get_cached_session', 'clear_cache', 'engine']
|
||||
__all__ = ['User', 'Printer', 'Job', 'Stats', 'SystemLog', 'Base', 'GuestRequest', 'UserPermission', 'Notification', 'JobOrder', 'init_db', 'init_database', 'create_initial_admin', 'get_db_session', 'get_cached_session', 'clear_cache', 'engine']
|
||||
|
||||
# ===== DATENBANK-KONFIGURATION MIT WAL UND OPTIMIERUNGEN =====
|
||||
|
||||
@ -819,25 +819,29 @@ class GuestRequest(Base):
|
||||
rejected_by_user = relationship("User", foreign_keys=[rejected_by]) # Admin der abgelehnt hat
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
# Cache-Key für GuestRequest-Dict
|
||||
cache_key = get_cache_key("GuestRequest", self.id, "dict")
|
||||
cached_result = get_cache(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
result = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"email": self.email,
|
||||
"reason": self.reason,
|
||||
"duration_min": self.duration_min,
|
||||
"duration_minutes": self.duration_minutes or self.duration_min, # Fallback auf duration_min
|
||||
"file_name": self.file_name,
|
||||
"file_path": self.file_path,
|
||||
"copies": self.copies,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"status": self.status,
|
||||
"printer_id": self.printer_id,
|
||||
"assigned_printer_id": self.assigned_printer_id,
|
||||
"otp_code": self.otp_code,
|
||||
"otp_expires_at": self.otp_expires_at.isoformat() if self.otp_expires_at else None,
|
||||
"otp_used_at": self.otp_used_at.isoformat() if self.otp_used_at else None,
|
||||
"job_id": self.job_id,
|
||||
"author_ip": self.author_ip,
|
||||
"otp_used_at": self.otp_used_at.isoformat() if self.otp_used_at else None,
|
||||
"file_name": self.file_name,
|
||||
"file_path": self.file_path,
|
||||
"copies": self.copies,
|
||||
"processed_by": self.processed_by,
|
||||
"processed_at": self.processed_at.isoformat() if self.processed_at else None,
|
||||
"approval_notes": self.approval_notes,
|
||||
@ -847,60 +851,304 @@ class GuestRequest(Base):
|
||||
"rejected_at": self.rejected_at.isoformat() if self.rejected_at else None,
|
||||
"approved_by": self.approved_by,
|
||||
"rejected_by": self.rejected_by,
|
||||
"printer": self.printer.to_dict() if self.printer else None,
|
||||
"assigned_printer": self.assigned_printer.to_dict() if self.assigned_printer else None,
|
||||
"job": self.job.to_dict() if self.job else None,
|
||||
"processed_by_user": self.processed_by_user.to_dict() if self.processed_by_user else None,
|
||||
"approved_by_user": self.approved_by_user.to_dict() if self.approved_by_user else None,
|
||||
"rejected_by_user": self.rejected_by_user.to_dict() if self.rejected_by_user else None
|
||||
"otp_expires_at": self.otp_expires_at.isoformat() if self.otp_expires_at else None,
|
||||
"assigned_printer_id": self.assigned_printer_id,
|
||||
}
|
||||
|
||||
# Ergebnis cachen (5 Minuten)
|
||||
set_cache(cache_key, result, 300)
|
||||
return result
|
||||
|
||||
def generate_otp(self) -> str:
|
||||
"""
|
||||
Generiert einen einmaligen OTP-Code und speichert den Hash in der Datenbank.
|
||||
|
||||
Returns:
|
||||
str: Der generierte OTP-Code im Klartext
|
||||
Generiert einen neuen OTP-Code und speichert den Hash.
|
||||
"""
|
||||
# Generiere 6-stelligen Code (Großbuchstaben + Ziffern)
|
||||
otp_plain = ''.join(secrets.choice('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ') for _ in range(6))
|
||||
otp_plain = secrets.token_hex(8) # 16-stelliger hexadezimaler Code
|
||||
|
||||
# Hash für die Speicherung erstellen
|
||||
# Hash des OTP-Codes speichern
|
||||
otp_bytes = otp_plain.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
otp_hash = bcrypt.hashpw(otp_bytes, salt).decode('utf-8')
|
||||
self.otp_code = bcrypt.hashpw(otp_bytes, salt).decode('utf-8')
|
||||
|
||||
# Hash in der Datenbank speichern
|
||||
self.otp_code = otp_hash
|
||||
logger.info(f"OTP generiert für Guest Request {self.id}")
|
||||
|
||||
# Cache invalidieren
|
||||
invalidate_model_cache("GuestRequest", self.id)
|
||||
|
||||
return otp_plain
|
||||
|
||||
def verify_otp(self, otp_plain: str) -> bool:
|
||||
"""
|
||||
Verifiziert einen OTP-Code gegen den gespeicherten Hash.
|
||||
|
||||
Args:
|
||||
otp_plain: Der zu prüfende OTP-Code im Klartext
|
||||
|
||||
Returns:
|
||||
bool: True wenn der Code korrekt ist, False andernfalls
|
||||
Verifiziert einen OTP-Code.
|
||||
"""
|
||||
if not self.otp_code or not otp_plain:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Code normalisieren (Großbuchstaben)
|
||||
otp_plain = otp_plain.upper().strip()
|
||||
|
||||
# Hash verifizieren
|
||||
otp_bytes = otp_plain.encode('utf-8')
|
||||
stored_hash = self.otp_code.encode('utf-8')
|
||||
hash_bytes = self.otp_code.encode('utf-8')
|
||||
|
||||
return bcrypt.checkpw(otp_bytes, stored_hash)
|
||||
except Exception:
|
||||
is_valid = bcrypt.checkpw(otp_bytes, hash_bytes)
|
||||
|
||||
if is_valid:
|
||||
self.otp_used_at = datetime.now()
|
||||
logger.info(f"OTP erfolgreich verifiziert für Guest Request {self.id}")
|
||||
|
||||
# Cache invalidieren
|
||||
invalidate_model_cache("GuestRequest", self.id)
|
||||
else:
|
||||
logger.warning(f"Ungültiger OTP-Code für Guest Request {self.id}")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei OTP-Verifizierung: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class JobOrder(Base):
|
||||
"""
|
||||
Job-Reihenfolge für Drucker im Drag & Drop System.
|
||||
Speichert die benutzerdefinierte Reihenfolge der Jobs pro Drucker.
|
||||
"""
|
||||
__tablename__ = "job_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||
job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False)
|
||||
order_position = Column(Integer, nullable=False) # Position in der Reihenfolge (0-basiert)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
last_modified_by = Column(Integer, ForeignKey("users.id"), nullable=True) # Wer die Reihenfolge geändert hat
|
||||
|
||||
# Beziehungen
|
||||
printer = relationship("Printer", foreign_keys=[printer_id])
|
||||
job = relationship("Job", foreign_keys=[job_id])
|
||||
modified_by_user = relationship("User", foreign_keys=[last_modified_by])
|
||||
|
||||
# Eindeutige Kombination: Ein Job kann nur eine Position pro Drucker haben
|
||||
__table_args__ = (
|
||||
# Sicherstellen, dass jeder Job nur einmal pro Drucker existiert
|
||||
# und jede Position pro Drucker nur einmal vergeben wird
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Konvertiert JobOrder zu Dictionary.
|
||||
"""
|
||||
cache_key = get_cache_key("JobOrder", f"{self.printer_id}_{self.job_id}", "dict")
|
||||
cached_result = get_cache(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
result = {
|
||||
"id": self.id,
|
||||
"printer_id": self.printer_id,
|
||||
"job_id": self.job_id,
|
||||
"order_position": self.order_position,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"last_modified_by": self.last_modified_by
|
||||
}
|
||||
|
||||
# Ergebnis cachen (2 Minuten)
|
||||
set_cache(cache_key, result, 120)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_order_for_printer(cls, printer_id: int) -> List['JobOrder']:
|
||||
"""
|
||||
Holt die Job-Reihenfolge für einen bestimmten Drucker.
|
||||
"""
|
||||
cache_key = get_cache_key("JobOrder", printer_id, "printer_order")
|
||||
cached_orders = get_cache(cache_key)
|
||||
|
||||
if cached_orders is not None:
|
||||
return cached_orders
|
||||
|
||||
with get_cached_session() as session:
|
||||
orders = session.query(cls).filter(
|
||||
cls.printer_id == printer_id
|
||||
).order_by(cls.order_position).all()
|
||||
|
||||
# Ergebnis cachen (1 Minute für häufige Abfragen)
|
||||
set_cache(cache_key, orders, 60)
|
||||
|
||||
return orders
|
||||
|
||||
@classmethod
|
||||
def update_printer_order(cls, printer_id: int, job_ids: List[int],
|
||||
modified_by_user_id: int = None) -> bool:
|
||||
"""
|
||||
Aktualisiert die komplette Job-Reihenfolge für einen Drucker.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
job_ids: Liste der Job-IDs in der gewünschten Reihenfolge
|
||||
modified_by_user_id: ID des Users der die Änderung durchführt
|
||||
|
||||
Returns:
|
||||
bool: True wenn erfolgreich, False bei Fehler
|
||||
"""
|
||||
try:
|
||||
with get_cached_session() as session:
|
||||
# Validiere dass alle Jobs existieren und zum Drucker gehören
|
||||
valid_jobs = session.query(Job).filter(
|
||||
Job.id.in_(job_ids),
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).all()
|
||||
|
||||
if len(valid_jobs) != len(job_ids):
|
||||
logger.warning(f"Nicht alle Jobs gültig für Drucker {printer_id}. "
|
||||
f"Erwartet: {len(job_ids)}, Gefunden: {len(valid_jobs)}")
|
||||
return False
|
||||
|
||||
# Alte Reihenfolge-Einträge für diesen Drucker löschen
|
||||
session.query(cls).filter(cls.printer_id == printer_id).delete()
|
||||
|
||||
# Neue Reihenfolge-Einträge erstellen
|
||||
for position, job_id in enumerate(job_ids):
|
||||
order_entry = cls(
|
||||
printer_id=printer_id,
|
||||
job_id=job_id,
|
||||
order_position=position,
|
||||
last_modified_by=modified_by_user_id
|
||||
)
|
||||
session.add(order_entry)
|
||||
|
||||
session.commit()
|
||||
|
||||
# Cache invalidieren
|
||||
clear_cache(f"JobOrder:{printer_id}")
|
||||
|
||||
logger.info(f"Job-Reihenfolge für Drucker {printer_id} erfolgreich aktualisiert. "
|
||||
f"Jobs: {job_ids}, Benutzer: {modified_by_user_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Aktualisieren der Job-Reihenfolge für Drucker {printer_id}: {str(e)}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_ordered_job_ids(cls, printer_id: int) -> List[int]:
|
||||
"""
|
||||
Holt die Job-IDs in der korrekten Reihenfolge für einen Drucker.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
Returns:
|
||||
List[int]: Liste der Job-IDs in der richtigen Reihenfolge
|
||||
"""
|
||||
cache_key = get_cache_key("JobOrder", printer_id, "job_ids")
|
||||
cached_ids = get_cache(cache_key)
|
||||
|
||||
if cached_ids is not None:
|
||||
return cached_ids
|
||||
|
||||
try:
|
||||
with get_cached_session() as session:
|
||||
orders = session.query(cls).filter(
|
||||
cls.printer_id == printer_id
|
||||
).order_by(cls.order_position).all()
|
||||
|
||||
job_ids = [order.job_id for order in orders]
|
||||
|
||||
# Ergebnis cachen (1 Minute)
|
||||
set_cache(cache_key, job_ids, 60)
|
||||
|
||||
return job_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden der Job-Reihenfolge für Drucker {printer_id}: {str(e)}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def remove_job_from_orders(cls, job_id: int):
|
||||
"""
|
||||
Entfernt einen Job aus allen Drucker-Reihenfolgen (z.B. wenn Job gelöscht wird).
|
||||
|
||||
Args:
|
||||
job_id: ID des zu entfernenden Jobs
|
||||
"""
|
||||
try:
|
||||
with get_cached_session() as session:
|
||||
# Alle Order-Einträge für diesen Job finden
|
||||
orders_to_remove = session.query(cls).filter(cls.job_id == job_id).all()
|
||||
printer_ids = {order.printer_id for order in orders_to_remove}
|
||||
|
||||
# Order-Einträge löschen
|
||||
session.query(cls).filter(cls.job_id == job_id).delete()
|
||||
|
||||
# Positionen neu ordnen für betroffene Drucker
|
||||
for printer_id in printer_ids:
|
||||
remaining_orders = session.query(cls).filter(
|
||||
cls.printer_id == printer_id
|
||||
).order_by(cls.order_position).all()
|
||||
|
||||
# Positionen neu setzen (lückenlos)
|
||||
for new_position, order in enumerate(remaining_orders):
|
||||
order.order_position = new_position
|
||||
order.updated_at = datetime.now()
|
||||
|
||||
session.commit()
|
||||
|
||||
# Cache für betroffene Drucker invalidieren
|
||||
for printer_id in printer_ids:
|
||||
clear_cache(f"JobOrder:{printer_id}")
|
||||
|
||||
logger.info(f"Job {job_id} aus allen Drucker-Reihenfolgen entfernt. "
|
||||
f"Betroffene Drucker: {list(printer_ids)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Entfernen des Jobs {job_id} aus Reihenfolgen: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def cleanup_invalid_orders(cls):
|
||||
"""
|
||||
Bereinigt ungültige Order-Einträge (Jobs die nicht mehr existieren oder abgeschlossen sind).
|
||||
"""
|
||||
try:
|
||||
with get_cached_session() as session:
|
||||
# Finde Order-Einträge mit nicht existierenden oder abgeschlossenen Jobs
|
||||
invalid_orders = session.query(cls).join(Job).filter(
|
||||
Job.status.in_(['finished', 'aborted', 'cancelled'])
|
||||
).all()
|
||||
|
||||
printer_ids = {order.printer_id for order in invalid_orders}
|
||||
|
||||
# Ungültige Einträge löschen
|
||||
session.query(cls).join(Job).filter(
|
||||
Job.status.in_(['finished', 'aborted', 'cancelled'])
|
||||
).delete(synchronize_session='fetch')
|
||||
|
||||
# Positionen für betroffene Drucker neu ordnen
|
||||
for printer_id in printer_ids:
|
||||
remaining_orders = session.query(cls).filter(
|
||||
cls.printer_id == printer_id
|
||||
).order_by(cls.order_position).all()
|
||||
|
||||
for new_position, order in enumerate(remaining_orders):
|
||||
order.order_position = new_position
|
||||
order.updated_at = datetime.now()
|
||||
|
||||
session.commit()
|
||||
|
||||
# Cache für betroffene Drucker invalidieren
|
||||
for printer_id in printer_ids:
|
||||
clear_cache(f"JobOrder:{printer_id}")
|
||||
|
||||
logger.info(f"Bereinigung der Job-Reihenfolgen abgeschlossen. "
|
||||
f"Entfernte Einträge: {len(invalid_orders)}, "
|
||||
f"Betroffene Drucker: {list(printer_ids)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei der Bereinigung der Job-Reihenfolgen: {str(e)}")
|
||||
|
||||
|
||||
# ===== DATENBANK-INITIALISIERUNG MIT OPTIMIERUNGEN =====
|
||||
|
||||
def init_db() -> None:
|
||||
|
511
backend/templates/socket_test.html
Normal file
511
backend/templates/socket_test.html
Normal file
@ -0,0 +1,511 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Steckdosen-Test - Mercedes-Benz TBA Marienfelde{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.test-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .test-card {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.risk-low { border-left: 4px solid #10b981; }
|
||||
.risk-medium { border-left: 4px solid #f59e0b; }
|
||||
.risk-high { border-left: 4px solid #ef4444; }
|
||||
|
||||
.socket-online { color: #10b981; }
|
||||
.socket-offline { color: #ef4444; }
|
||||
.socket-error { color: #f59e0b; }
|
||||
|
||||
.test-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.test-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-test-on { background: #16a34a; color: white; }
|
||||
.btn-test-off { background: #ef4444; color: white; }
|
||||
.btn-test-status { background: #0073ce; color: white; }
|
||||
|
||||
.warning-banner {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.05) 100%);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: linear-gradient(90deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top: 2px solid #0073ce;
|
||||
border-radius: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-card p-6">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-16 h-16 bg-red-600 text-white rounded-xl flex items-center justify-center">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-mercedes-black dark:text-white">⚡ Steckdosen-Test</h1>
|
||||
<p class="text-mercedes-gray dark:text-slate-400 mt-1">Sichere Testfunktion für Ausbilder und Administratoren</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitshinweis -->
|
||||
<div class="warning-banner">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.268 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-red-800 dark:text-red-200">⚠️ SICHERHEITSHINWEIS</h3>
|
||||
<p class="text-red-700 dark:text-red-300 mt-1">
|
||||
Diese Funktion ist nur für geschulte Ausbilder und Administratoren bestimmt.
|
||||
Prüfen Sie immer den Status vor dem Ein-/Ausschalten von Steckdosen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht aller Steckdosen -->
|
||||
<div class="dashboard-card p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-mercedes-black dark:text-white">Übersicht aller Steckdosen</h2>
|
||||
<button onclick="loadAllSocketsStatus()" class="test-button btn-test-status">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Alle Status aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div id="socket-summary" class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||
<!-- Wird per JavaScript gefüllt -->
|
||||
</div>
|
||||
|
||||
<!-- Steckdosen-Liste -->
|
||||
<div id="all-sockets-list" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="ml-2">Lade Steckdosen-Status...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Einzeltest -->
|
||||
<div class="dashboard-card p-6">
|
||||
<h2 class="text-2xl font-bold text-mercedes-black dark:text-white mb-6">Einzelne Steckdose testen</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Drucker-Auswahl -->
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Drucker auswählen:
|
||||
</label>
|
||||
<select id="printer-select" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Bitte Drucker auswählen...</option>
|
||||
</select>
|
||||
|
||||
<button onclick="loadSingleSocketStatus()" id="load-status-btn"
|
||||
class="test-button btn-test-status w-full" disabled>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
|
||||
</svg>
|
||||
Status prüfen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status-Anzeige -->
|
||||
<div id="single-socket-status" class="space-y-4">
|
||||
<div class="info-banner">
|
||||
<p class="text-blue-700 dark:text-blue-300">
|
||||
Wählen Sie einen Drucker aus um den Steckdosen-Status zu prüfen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test-Bestätigungsmodal -->
|
||||
<div id="test-confirmation-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.268 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold">Test bestätigen</h3>
|
||||
</div>
|
||||
|
||||
<div id="test-modal-content">
|
||||
<!-- Wird per JavaScript gefüllt -->
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button onclick="closeTestModal()" class="test-button bg-gray-500 text-white flex-1">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onclick="executeTest()" id="confirm-test-btn" class="test-button bg-red-600 text-white flex-1">
|
||||
Test durchführen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentTestData = null;
|
||||
let printers = [];
|
||||
|
||||
// Seite initialisieren
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPrinters();
|
||||
loadAllSocketsStatus();
|
||||
});
|
||||
|
||||
// Drucker laden
|
||||
async function loadPrinters() {
|
||||
try {
|
||||
const response = await fetch('/api/printers');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
printers = data.printers;
|
||||
const select = document.getElementById('printer-select');
|
||||
select.innerHTML = '<option value="">Bitte Drucker auswählen...</option>';
|
||||
|
||||
printers.forEach(printer => {
|
||||
if (printer.plug_ip) { // Nur Drucker mit Steckdose
|
||||
const option = document.createElement('option');
|
||||
option.value = printer.id;
|
||||
option.textContent = `${printer.name} (${printer.location || 'Unbekannter Standort'})`;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
select.addEventListener('change', function() {
|
||||
const loadBtn = document.getElementById('load-status-btn');
|
||||
loadBtn.disabled = !this.value;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Drucker:', error);
|
||||
showNotification('Fehler beim Laden der Drucker', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Steckdosen-Status laden
|
||||
async function loadAllSocketsStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/printers/test/all-sockets');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displaySocketsSummary(data.summary);
|
||||
displayAllSockets(data.sockets);
|
||||
} else {
|
||||
throw new Error(data.error || 'Unbekannter Fehler');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Steckdosen:', error);
|
||||
showNotification('Fehler beim Laden der Steckdosen: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelnen Steckdosen-Status laden
|
||||
async function loadSingleSocketStatus() {
|
||||
const printerId = document.getElementById('printer-select').value;
|
||||
if (!printerId) return;
|
||||
|
||||
const statusDiv = document.getElementById('single-socket-status');
|
||||
statusDiv.innerHTML = '<div class="flex items-center"><div class="loading-spinner"></div><span class="ml-2">Status wird geladen...</span></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/printers/test/socket/${printerId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displaySingleSocketStatus(data);
|
||||
} else {
|
||||
statusDiv.innerHTML = `<div class="warning-banner"><p class="text-red-700">${data.error}</p></div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Socket-Status:', error);
|
||||
statusDiv.innerHTML = `<div class="warning-banner"><p class="text-red-700">Fehler: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Zusammenfassung anzeigen
|
||||
function displaySocketsSummary(summary) {
|
||||
const summaryDiv = document.getElementById('socket-summary');
|
||||
summaryDiv.innerHTML = `
|
||||
<div class="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">${summary.total_sockets}</div>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-300">Gesamt</div>
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${summary.online}</div>
|
||||
<div class="text-sm text-green-800 dark:text-green-300">Online</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-600 dark:text-gray-400">${summary.offline}</div>
|
||||
<div class="text-sm text-gray-800 dark:text-gray-300">Offline</div>
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">${summary.error}</div>
|
||||
<div class="text-sm text-red-800 dark:text-red-300">Fehler</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 dark:bg-orange-900 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">${summary.with_warnings}</div>
|
||||
<div class="text-sm text-orange-800 dark:text-orange-300">Mit Warnungen</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Alle Steckdosen anzeigen
|
||||
function displayAllSockets(sockets) {
|
||||
const listDiv = document.getElementById('all-sockets-list');
|
||||
|
||||
if (sockets.length === 0) {
|
||||
listDiv.innerHTML = '<div class="col-span-full text-center p-8"><p class="text-gray-500">Keine konfigurierten Steckdosen gefunden.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listDiv.innerHTML = sockets.map(socket => `
|
||||
<div class="test-card p-4 ${getRiskClass(socket.warnings.length)}">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">${socket.printer.name}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">${socket.printer.location || 'Unbekannter Standort'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="socket-${socket.socket.status} font-semibold">
|
||||
${getStatusText(socket.socket.status, socket.socket.device_on)}
|
||||
</div>
|
||||
${socket.socket.current_power ? `<div class="text-sm text-gray-600">${socket.socket.current_power}W</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${socket.warnings.length > 0 ? `
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded p-3 mb-4">
|
||||
<div class="font-medium text-yellow-800 dark:text-yellow-200 mb-1">⚠️ Warnungen:</div>
|
||||
${socket.warnings.map(warning => `<div class="text-sm text-yellow-700 dark:text-yellow-300">• ${warning}</div>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="testSocketControl(${socket.printer.id}, 'on')"
|
||||
class="test-button btn-test-on flex-1 ${socket.socket.device_on ? 'opacity-50' : ''}">
|
||||
⚡ Einschalten
|
||||
</button>
|
||||
<button onclick="testSocketControl(${socket.printer.id}, 'off')"
|
||||
class="test-button btn-test-off flex-1 ${!socket.socket.device_on ? 'opacity-50' : ''}">
|
||||
🔌 Ausschalten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Einzelstatus anzeigen
|
||||
function displaySingleSocketStatus(data) {
|
||||
const statusDiv = document.getElementById('single-socket-status');
|
||||
const riskClass = getRiskClass(data.safety.warnings.length);
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="test-card p-4 ${riskClass}">
|
||||
<div class="mb-4">
|
||||
<h3 class="font-semibold text-lg">${data.printer.name}</h3>
|
||||
<p class="text-sm text-gray-600">${data.printer.location}</p>
|
||||
<div class="mt-2">
|
||||
<span class="socket-${data.socket.status} font-semibold">
|
||||
${getStatusText(data.socket.status, data.socket.info?.device_on)}
|
||||
</span>
|
||||
${data.socket.info?.current_power ? ` • ${data.socket.info.current_power}W` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${data.safety.warnings.length > 0 ? `
|
||||
<div class="warning-banner mb-4">
|
||||
<div class="font-medium mb-2">⚠️ Sicherheitswarnungen:</div>
|
||||
${data.safety.warnings.map(warning => `<div>• ${warning}</div>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${data.safety.recommendations.length > 0 ? `
|
||||
<div class="info-banner mb-4">
|
||||
<div class="font-medium mb-2">💡 Empfehlungen:</div>
|
||||
${data.safety.recommendations.map(rec => `<div>• ${rec}</div>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="testSocketControl(${data.printer.id}, 'on')"
|
||||
class="test-button btn-test-on flex-1">
|
||||
⚡ Test: Einschalten
|
||||
</button>
|
||||
<button onclick="testSocketControl(${data.printer.id}, 'off')"
|
||||
class="test-button btn-test-off flex-1">
|
||||
🔌 Test: Ausschalten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Test-Modal öffnen
|
||||
function testSocketControl(printerId, action) {
|
||||
const printer = printers.find(p => p.id == printerId);
|
||||
if (!printer) return;
|
||||
|
||||
currentTestData = { printerId, action, printer };
|
||||
|
||||
const modal = document.getElementById('test-confirmation-modal');
|
||||
const content = document.getElementById('test-modal-content');
|
||||
|
||||
content.innerHTML = `
|
||||
<p class="mb-4">
|
||||
<strong>Drucker:</strong> ${printer.name}<br>
|
||||
<strong>Aktion:</strong> Steckdose ${action === 'on' ? 'einschalten' : 'ausschalten'}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Grund für den Test:</label>
|
||||
<input type="text" id="test-reason" class="w-full p-2 border rounded"
|
||||
placeholder="z.B. Routinetest, Wartung, etc." value="Routinetest">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" id="force-test" class="mr-2">
|
||||
<span class="text-sm">Sicherheitswarnungen überschreiben (force)</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Test ausführen
|
||||
async function executeTest() {
|
||||
if (!currentTestData) return;
|
||||
|
||||
const reason = document.getElementById('test-reason').value || 'Routinetest';
|
||||
const force = document.getElementById('force-test').checked;
|
||||
|
||||
const confirmBtn = document.getElementById('confirm-test-btn');
|
||||
confirmBtn.innerHTML = '<div class="loading-spinner"></div> Teste...';
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/printers/test/socket/${currentTestData.printerId}/control`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('meta[name=csrf-token]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: currentTestData.action,
|
||||
test_reason: reason,
|
||||
force: force
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showNotification(`Test erfolgreich: ${data.message}`, 'success');
|
||||
loadAllSocketsStatus();
|
||||
loadSingleSocketStatus();
|
||||
} else {
|
||||
if (data.requires_force) {
|
||||
showNotification('Test blockiert: ' + data.error + ' Aktivieren Sie "force" um fortzufahren.', 'warning');
|
||||
} else {
|
||||
showNotification('Test fehlgeschlagen: ' + data.error, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Test:', error);
|
||||
showNotification('Fehler beim Test: ' + error.message, 'error');
|
||||
} finally {
|
||||
closeTestModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
function closeTestModal() {
|
||||
const modal = document.getElementById('test-confirmation-modal');
|
||||
modal.classList.add('hidden');
|
||||
currentTestData = null;
|
||||
|
||||
const confirmBtn = document.getElementById('confirm-test-btn');
|
||||
confirmBtn.innerHTML = 'Test durchführen';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Hilfsfunktionen
|
||||
function getRiskClass(warningCount) {
|
||||
if (warningCount === 0) return 'risk-low';
|
||||
if (warningCount <= 2) return 'risk-medium';
|
||||
return 'risk-high';
|
||||
}
|
||||
|
||||
function getStatusText(status, deviceOn) {
|
||||
switch (status) {
|
||||
case 'online': return deviceOn ? '🟢 Eingeschaltet' : '🔴 Ausgeschaltet';
|
||||
case 'offline': return '🔴 Ausgeschaltet';
|
||||
case 'error': return '⚠️ Fehler';
|
||||
default: return '❓ Unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
// Einfache Benachrichtigung - kann durch Toast-System ersetzt werden
|
||||
alert(message);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -21,7 +21,7 @@ from flask import request, jsonify, current_app
|
||||
from flask_login import current_user
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import Job, Printer, get_db_session
|
||||
from models import Job, Printer, JobOrder, get_db_session
|
||||
from utils.file_manager import save_job_file, save_temp_file
|
||||
from config.settings import ALLOWED_EXTENSIONS, MAX_FILE_SIZE, UPLOAD_FOLDER
|
||||
|
||||
@ -119,34 +119,282 @@ class DragDropManager:
|
||||
def update_job_order(self, printer_id: int, job_ids: List[int]) -> bool:
|
||||
"""Aktualisiert die Job-Reihenfolge für einen Drucker"""
|
||||
try:
|
||||
with get_db_session() as db_session:
|
||||
# Validiere dass alle Jobs existieren und zum Drucker gehören
|
||||
jobs = db_session.query(Job).filter(
|
||||
Job.id.in_(job_ids),
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).all()
|
||||
|
||||
if len(jobs) != len(job_ids):
|
||||
logger.warning(f"Nicht alle Jobs gefunden oder gehören zu Drucker {printer_id}")
|
||||
return False
|
||||
|
||||
# Aktuelle Benutzer-ID für Audit-Trail
|
||||
user_id = current_user.id if current_user.is_authenticated else None
|
||||
|
||||
# Validierung der Eingaben
|
||||
if not isinstance(printer_id, int) or printer_id <= 0:
|
||||
logger.error(f"Ungültige Drucker-ID: {printer_id}")
|
||||
return False
|
||||
|
||||
if not isinstance(job_ids, list) or not job_ids:
|
||||
logger.error(f"Ungültige Job-IDs Liste: {job_ids}")
|
||||
return False
|
||||
|
||||
# Duplikate entfernen und Reihenfolge beibehalten
|
||||
unique_job_ids = []
|
||||
seen = set()
|
||||
for job_id in job_ids:
|
||||
if job_id not in seen:
|
||||
unique_job_ids.append(job_id)
|
||||
seen.add(job_id)
|
||||
|
||||
if len(unique_job_ids) != len(job_ids):
|
||||
logger.warning(f"Duplikate in Job-IDs entfernt: {job_ids} -> {unique_job_ids}")
|
||||
job_ids = unique_job_ids
|
||||
|
||||
# Datenbank-Implementierung mit JobOrder-Tabelle
|
||||
success = JobOrder.update_printer_order(
|
||||
printer_id=printer_id,
|
||||
job_ids=job_ids,
|
||||
modified_by_user_id=user_id
|
||||
)
|
||||
|
||||
if success:
|
||||
# Cache aktualisieren
|
||||
self.job_order_cache[printer_id] = job_ids
|
||||
|
||||
# Optional: In Datenbank speichern (erweiterte Implementierung)
|
||||
# Hier könnte man ein separates Job-Order-Table verwenden
|
||||
logger.info(f"Job-Reihenfolge für Drucker {printer_id} erfolgreich aktualisiert: {job_ids}")
|
||||
logger.info(f"Aktualisiert von Benutzer: {user_id}")
|
||||
|
||||
# Optional: Bereinigung ungültiger Einträge im Hintergrund
|
||||
self._schedule_cleanup()
|
||||
|
||||
logger.info(f"Job-Reihenfolge für Drucker {printer_id} aktualisiert: {job_ids}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Fehler beim Speichern der Job-Reihenfolge in der Datenbank")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Aktualisieren der Job-Reihenfolge: {str(e)}")
|
||||
logger.error(f"Unerwarteter Fehler beim Aktualisieren der Job-Reihenfolge: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_job_order(self, printer_id: int) -> List[int]:
|
||||
"""Holt die aktuelle Job-Reihenfolge für einen Drucker"""
|
||||
return self.job_order_cache.get(printer_id, [])
|
||||
try:
|
||||
# Erst aus Cache versuchen
|
||||
if printer_id in self.job_order_cache:
|
||||
cached_order = self.job_order_cache[printer_id]
|
||||
logger.debug(f"Job-Reihenfolge aus Cache für Drucker {printer_id}: {cached_order}")
|
||||
return cached_order
|
||||
|
||||
# Aus Datenbank laden
|
||||
job_ids = JobOrder.get_ordered_job_ids(printer_id)
|
||||
|
||||
# Cache aktualisieren
|
||||
self.job_order_cache[printer_id] = job_ids
|
||||
|
||||
logger.debug(f"Job-Reihenfolge aus Datenbank geladen für Drucker {printer_id}: {job_ids}")
|
||||
return job_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden der Job-Reihenfolge für Drucker {printer_id}: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_ordered_jobs_for_printer(self, printer_id: int) -> List[Job]:
|
||||
"""
|
||||
Holt die Jobs für einen Drucker in der korrekten Reihenfolge.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
Returns:
|
||||
List[Job]: Jobs sortiert nach der benutzerdefinierten Reihenfolge
|
||||
"""
|
||||
try:
|
||||
# Job-IDs in der korrekten Reihenfolge holen
|
||||
ordered_job_ids = self.get_job_order(printer_id)
|
||||
|
||||
if not ordered_job_ids:
|
||||
# Fallback: Jobs nach Standard-Kriterien sortieren
|
||||
with get_db_session() as db_session:
|
||||
jobs = db_session.query(Job).filter(
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).order_by(Job.created_at).all()
|
||||
return jobs
|
||||
|
||||
# Jobs in der definierten Reihenfolge laden
|
||||
with get_db_session() as db_session:
|
||||
# Alle relevanten Jobs laden
|
||||
all_jobs = db_session.query(Job).filter(
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).all()
|
||||
|
||||
# Dictionary für schnelle Zugriffe
|
||||
jobs_dict = {job.id: job for job in all_jobs}
|
||||
|
||||
# Jobs in der korrekten Reihenfolge zusammenstellen
|
||||
ordered_jobs = []
|
||||
for job_id in ordered_job_ids:
|
||||
if job_id in jobs_dict:
|
||||
ordered_jobs.append(jobs_dict[job_id])
|
||||
|
||||
# Jobs hinzufügen, die nicht in der Reihenfolge sind (neue Jobs)
|
||||
ordered_job_ids_set = set(ordered_job_ids)
|
||||
unordered_jobs = [job for job in all_jobs if job.id not in ordered_job_ids_set]
|
||||
|
||||
if unordered_jobs:
|
||||
# Neue Jobs nach Erstellungsdatum sortieren und anhängen
|
||||
unordered_jobs.sort(key=lambda x: x.created_at)
|
||||
ordered_jobs.extend(unordered_jobs)
|
||||
|
||||
# Reihenfolge automatisch aktualisieren für neue Jobs
|
||||
new_order = [job.id for job in ordered_jobs]
|
||||
self.update_job_order(printer_id, new_order)
|
||||
|
||||
logger.debug(f"Jobs für Drucker {printer_id} in Reihenfolge geladen: {len(ordered_jobs)} Jobs")
|
||||
return ordered_jobs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden der sortierten Jobs für Drucker {printer_id}: {str(e)}")
|
||||
|
||||
# Fallback: Unsortierte Jobs zurückgeben
|
||||
try:
|
||||
with get_db_session() as db_session:
|
||||
jobs = db_session.query(Job).filter(
|
||||
Job.printer_id == printer_id,
|
||||
Job.status.in_(['scheduled', 'paused'])
|
||||
).order_by(Job.created_at).all()
|
||||
return jobs
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Auch Fallback fehlgeschlagen: {str(fallback_error)}")
|
||||
return []
|
||||
|
||||
def remove_job_from_order(self, job_id: int) -> bool:
|
||||
"""
|
||||
Entfernt einen Job aus allen Drucker-Reihenfolgen.
|
||||
|
||||
Args:
|
||||
job_id: ID des zu entfernenden Jobs
|
||||
|
||||
Returns:
|
||||
bool: True wenn erfolgreich
|
||||
"""
|
||||
try:
|
||||
# Aus Datenbank entfernen
|
||||
JobOrder.remove_job_from_orders(job_id)
|
||||
|
||||
# Cache aktualisieren: Job aus allen Caches entfernen
|
||||
for printer_id in list(self.job_order_cache.keys()):
|
||||
if job_id in self.job_order_cache[printer_id]:
|
||||
self.job_order_cache[printer_id].remove(job_id)
|
||||
logger.debug(f"Job {job_id} aus Cache für Drucker {printer_id} entfernt")
|
||||
|
||||
logger.info(f"Job {job_id} erfolgreich aus allen Reihenfolgen entfernt")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Entfernen des Jobs {job_id} aus Reihenfolgen: {str(e)}")
|
||||
return False
|
||||
|
||||
def cleanup_invalid_orders(self):
|
||||
"""Bereinigt ungültige Job-Reihenfolgen"""
|
||||
try:
|
||||
# Datenbank-Bereinigung
|
||||
JobOrder.cleanup_invalid_orders()
|
||||
|
||||
# Cache komplett leeren (wird bei Bedarf neu geladen)
|
||||
self.job_order_cache.clear()
|
||||
|
||||
logger.info("Job-Reihenfolgen bereinigt")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei der Bereinigung der Job-Reihenfolgen: {str(e)}")
|
||||
|
||||
def _schedule_cleanup(self):
|
||||
"""Plant eine Bereinigung für später (non-blocking)"""
|
||||
try:
|
||||
# In produktiver Umgebung könnte hier ein Background-Task gestartet werden
|
||||
# Für jetzt führen wir eine schnelle Bereinigung durch
|
||||
import threading
|
||||
|
||||
def cleanup_worker():
|
||||
try:
|
||||
self.cleanup_invalid_orders()
|
||||
except Exception as e:
|
||||
logger.error(f"Hintergrund-Bereinigung fehlgeschlagen: {str(e)}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Konnte Hintergrund-Bereinigung nicht starten: {str(e)}")
|
||||
|
||||
def get_printer_summary(self, printer_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Erstellt eine Zusammenfassung der Job-Reihenfolge für einen Drucker.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
|
||||
Returns:
|
||||
Dict: Zusammenfassung mit Jobs, Reihenfolge, Statistiken
|
||||
"""
|
||||
try:
|
||||
ordered_jobs = self.get_ordered_jobs_for_printer(printer_id)
|
||||
|
||||
# Statistiken berechnen
|
||||
total_duration = sum(job.duration_minutes for job in ordered_jobs)
|
||||
total_jobs = len(ordered_jobs)
|
||||
|
||||
# Nächster Job
|
||||
next_job = ordered_jobs[0] if ordered_jobs else None
|
||||
|
||||
# Job-Details für die Ausgabe
|
||||
job_details = []
|
||||
for position, job in enumerate(ordered_jobs):
|
||||
job_details.append({
|
||||
'position': position,
|
||||
'job_id': job.id,
|
||||
'name': job.name,
|
||||
'duration_minutes': job.duration_minutes,
|
||||
'user_name': job.user.name if job.user else 'Unbekannt',
|
||||
'created_at': job.created_at.isoformat() if job.created_at else None,
|
||||
'status': job.status
|
||||
})
|
||||
|
||||
return {
|
||||
'printer_id': printer_id,
|
||||
'total_jobs': total_jobs,
|
||||
'total_duration_minutes': total_duration,
|
||||
'estimated_completion': self._calculate_completion_time(ordered_jobs),
|
||||
'next_job': {
|
||||
'id': next_job.id,
|
||||
'name': next_job.name,
|
||||
'user': next_job.user.name if next_job and next_job.user else None
|
||||
} if next_job else None,
|
||||
'jobs': job_details,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Erstellen der Drucker-Zusammenfassung für {printer_id}: {str(e)}")
|
||||
return {
|
||||
'printer_id': printer_id,
|
||||
'total_jobs': 0,
|
||||
'total_duration_minutes': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _calculate_completion_time(self, jobs: List[Job]) -> Optional[str]:
|
||||
"""Berechnet die geschätzte Fertigstellungszeit"""
|
||||
try:
|
||||
if not jobs:
|
||||
return None
|
||||
|
||||
total_minutes = sum(job.duration_minutes for job in jobs)
|
||||
completion_time = datetime.now()
|
||||
completion_time = completion_time.replace(
|
||||
minute=(completion_time.minute + total_minutes) % 60,
|
||||
hour=(completion_time.hour + (completion_time.minute + total_minutes) // 60) % 24
|
||||
)
|
||||
|
||||
return completion_time.isoformat()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Globale Instanz
|
||||
drag_drop_manager = DragDropManager()
|
||||
|
175
backend/utils/email_notification.py
Normal file
175
backend/utils/email_notification.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""
|
||||
Offline-kompatible E-Mail-Benachrichtigung für MYP-System
|
||||
========================================================
|
||||
|
||||
Da das System im Produktionsbetrieb offline läuft, werden alle E-Mail-Benachrichtigungen
|
||||
nur geloggt aber nicht tatsächlich versendet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("email_notification")
|
||||
|
||||
class OfflineEmailNotification:
|
||||
"""
|
||||
Offline-E-Mail-Benachrichtigung die nur Logs erstellt.
|
||||
Simuliert E-Mail-Versand für Offline-Betrieb.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = False # Immer deaktiviert im Offline-Modus
|
||||
logger.info("📧 Offline-E-Mail-Benachrichtigung initialisiert (kein echter E-Mail-Versand)")
|
||||
|
||||
def send_email(self, to: str, subject: str, body: str, **kwargs) -> bool:
|
||||
"""
|
||||
Simuliert E-Mail-Versand durch Logging.
|
||||
|
||||
Args:
|
||||
to: E-Mail-Empfänger
|
||||
subject: E-Mail-Betreff
|
||||
body: E-Mail-Inhalt
|
||||
**kwargs: Zusätzliche Parameter
|
||||
|
||||
Returns:
|
||||
bool: Immer True (Simulation erfolgreich)
|
||||
"""
|
||||
logger.info(f"📧 [OFFLINE-SIMULATION] E-Mail würde versendet werden:")
|
||||
logger.info(f" 📮 An: {to}")
|
||||
logger.info(f" 📋 Betreff: {subject}")
|
||||
logger.info(f" 📝 Inhalt: {body[:100]}{'...' if len(body) > 100 else ''}")
|
||||
logger.info(f" 🕒 Zeitpunkt: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
if kwargs:
|
||||
logger.info(f" ⚙️ Zusätzliche Parameter: {kwargs}")
|
||||
|
||||
return True
|
||||
|
||||
def send_notification_email(self, recipient: str, notification_type: str,
|
||||
data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Sendet Benachrichtigungs-E-Mail (Offline-Simulation).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
notification_type: Art der Benachrichtigung
|
||||
data: Daten für die Benachrichtigung
|
||||
|
||||
Returns:
|
||||
bool: Immer True (Simulation erfolgreich)
|
||||
"""
|
||||
subject = f"MYP-Benachrichtigung: {notification_type}"
|
||||
body = f"Benachrichtigung vom MYP-System:\n\n{data}"
|
||||
|
||||
return self.send_email(recipient, subject, body, notification_type=notification_type)
|
||||
|
||||
def send_maintenance_notification(self, recipient: str, task_title: str,
|
||||
task_description: str) -> bool:
|
||||
"""
|
||||
Sendet Wartungs-Benachrichtigung (Offline-Simulation).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
task_title: Titel der Wartungsaufgabe
|
||||
task_description: Beschreibung der Wartungsaufgabe
|
||||
|
||||
Returns:
|
||||
bool: Immer True (Simulation erfolgreich)
|
||||
"""
|
||||
subject = f"MYP-Wartungsaufgabe: {task_title}"
|
||||
body = f"""
|
||||
Neue Wartungsaufgabe im MYP-System:
|
||||
|
||||
Titel: {task_title}
|
||||
Beschreibung: {task_description}
|
||||
Erstellt: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Bitte loggen Sie sich in das MYP-System ein, um weitere Details zu sehen.
|
||||
"""
|
||||
|
||||
return self.send_email(recipient, subject, body, task_type="maintenance")
|
||||
|
||||
# Globale Instanz für einfache Verwendung
|
||||
email_notifier = OfflineEmailNotification()
|
||||
|
||||
def send_email_notification(recipient: str, subject: str, body: str, **kwargs) -> bool:
|
||||
"""
|
||||
Haupt-Funktion für E-Mail-Versand (Offline-kompatibel).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
subject: E-Mail-Betreff
|
||||
body: E-Mail-Inhalt
|
||||
**kwargs: Zusätzliche Parameter
|
||||
|
||||
Returns:
|
||||
bool: True wenn "erfolgreich" (geloggt)
|
||||
"""
|
||||
return email_notifier.send_email(recipient, subject, body, **kwargs)
|
||||
|
||||
def send_maintenance_email(recipient: str, task_title: str, task_description: str) -> bool:
|
||||
"""
|
||||
Sendet Wartungs-E-Mail (Offline-kompatibel).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
task_title: Titel der Wartungsaufgabe
|
||||
task_description: Beschreibung der Wartungsaufgabe
|
||||
|
||||
Returns:
|
||||
bool: True wenn "erfolgreich" (geloggt)
|
||||
"""
|
||||
return email_notifier.send_maintenance_notification(recipient, task_title, task_description)
|
||||
|
||||
def send_guest_approval_email(recipient: str, otp_code: str, expires_at: str) -> bool:
|
||||
"""
|
||||
Sendet Gastauftrags-Genehmigung-E-Mail (Offline-kompatibel).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
otp_code: OTP-Code für den Gastauftrag
|
||||
expires_at: Ablaufzeit des OTP-Codes
|
||||
|
||||
Returns:
|
||||
bool: True wenn "erfolgreich" (geloggt)
|
||||
"""
|
||||
subject = "MYP-Gastauftrag genehmigt"
|
||||
body = f"""
|
||||
Ihr Gastauftrag wurde genehmigt!
|
||||
|
||||
OTP-Code: {otp_code}
|
||||
Gültig bis: {expires_at}
|
||||
|
||||
Bitte verwenden Sie diesen Code am MYP-Terminal, um Ihren Druckauftrag zu starten.
|
||||
"""
|
||||
|
||||
return email_notifier.send_email(recipient, subject, body,
|
||||
otp_code=otp_code, expires_at=expires_at)
|
||||
|
||||
def send_guest_rejection_email(recipient: str, reason: str) -> bool:
|
||||
"""
|
||||
Sendet Gastauftrags-Ablehnungs-E-Mail (Offline-kompatibel).
|
||||
|
||||
Args:
|
||||
recipient: E-Mail-Empfänger
|
||||
reason: Grund für die Ablehnung
|
||||
|
||||
Returns:
|
||||
bool: True wenn "erfolgreich" (geloggt)
|
||||
"""
|
||||
subject = "MYP-Gastauftrag abgelehnt"
|
||||
body = f"""
|
||||
Ihr Gastauftrag wurde leider abgelehnt.
|
||||
|
||||
Grund: {reason}
|
||||
|
||||
Bei Fragen wenden Sie sich bitte an das MYP-Team.
|
||||
"""
|
||||
|
||||
return email_notifier.send_email(recipient, subject, body, rejection_reason=reason)
|
||||
|
||||
# Für Backward-Kompatibilität
|
||||
send_notification = send_email_notification
|
688
backend/utils/maintenance_system.py
Normal file
688
backend/utils/maintenance_system.py
Normal file
@ -0,0 +1,688 @@
|
||||
"""
|
||||
Wartungsplanungs- und Tracking-System für das MYP-System
|
||||
========================================================
|
||||
|
||||
Dieses Modul stellt umfassende Wartungsfunktionalität bereit:
|
||||
- Geplante und ungeplante Wartungen
|
||||
- Wartungsintervalle und Erinnerungen
|
||||
- Wartungshistorie und Berichte
|
||||
- Automatische Wartungsprüfungen
|
||||
- Ersatzteil-Management
|
||||
- Techniker-Zuweisungen
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
import threading
|
||||
import schedule
|
||||
import time
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import Printer, get_db_session
|
||||
from utils.email_notification import send_email_notification
|
||||
from utils.realtime_dashboard import emit_system_alert
|
||||
|
||||
logger = get_logger("maintenance")
|
||||
|
||||
class MaintenanceType(Enum):
|
||||
"""Arten von Wartungen"""
|
||||
PREVENTIVE = "preventive" # Vorbeugende Wartung
|
||||
CORRECTIVE = "corrective" # Reparatur/Korrektur
|
||||
EMERGENCY = "emergency" # Notfall-Wartung
|
||||
SCHEDULED = "scheduled" # Geplante Wartung
|
||||
INSPECTION = "inspection" # Inspektion
|
||||
|
||||
class MaintenanceStatus(Enum):
|
||||
"""Status einer Wartung"""
|
||||
PLANNED = "planned" # Geplant
|
||||
SCHEDULED = "scheduled" # Terminiert
|
||||
IN_PROGRESS = "in_progress" # In Bearbeitung
|
||||
COMPLETED = "completed" # Abgeschlossen
|
||||
CANCELLED = "cancelled" # Abgebrochen
|
||||
OVERDUE = "overdue" # Überfällig
|
||||
|
||||
class MaintenancePriority(Enum):
|
||||
"""Priorität einer Wartung"""
|
||||
LOW = "low" # Niedrig
|
||||
NORMAL = "normal" # Normal
|
||||
HIGH = "high" # Hoch
|
||||
CRITICAL = "critical" # Kritisch
|
||||
EMERGENCY = "emergency" # Notfall
|
||||
|
||||
@dataclass
|
||||
class MaintenanceTask:
|
||||
"""Wartungsaufgabe"""
|
||||
id: Optional[int] = None
|
||||
printer_id: int = None
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
maintenance_type: MaintenanceType = MaintenanceType.PREVENTIVE
|
||||
priority: MaintenancePriority = MaintenancePriority.NORMAL
|
||||
status: MaintenanceStatus = MaintenanceStatus.PLANNED
|
||||
scheduled_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
estimated_duration: int = 60 # Minuten
|
||||
actual_duration: Optional[int] = None
|
||||
assigned_technician: Optional[str] = None
|
||||
created_at: datetime = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
notes: str = ""
|
||||
required_parts: List[str] = None
|
||||
actual_parts_used: List[str] = None
|
||||
cost: Optional[float] = None
|
||||
checklist: List[Dict[str, Any]] = None
|
||||
photos: List[str] = None
|
||||
created_by: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class MaintenanceSchedule:
|
||||
"""Wartungsplan"""
|
||||
printer_id: int
|
||||
maintenance_type: MaintenanceType
|
||||
interval_days: int
|
||||
next_due: datetime
|
||||
last_completed: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
description: str = ""
|
||||
checklist_template: List[str] = None
|
||||
|
||||
@dataclass
|
||||
class MaintenanceMetrics:
|
||||
"""Wartungsmetriken"""
|
||||
total_tasks: int = 0
|
||||
completed_tasks: int = 0
|
||||
overdue_tasks: int = 0
|
||||
average_completion_time: float = 0.0
|
||||
total_cost: float = 0.0
|
||||
mtbf: float = 0.0 # Mean Time Between Failures
|
||||
mttr: float = 0.0 # Mean Time To Repair
|
||||
uptime_percentage: float = 0.0
|
||||
|
||||
class MaintenanceManager:
|
||||
"""Manager für Wartungsplanung und -tracking"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[int, MaintenanceTask] = {}
|
||||
self.schedules: Dict[int, List[MaintenanceSchedule]] = {}
|
||||
self.maintenance_history: List[MaintenanceTask] = []
|
||||
self.next_task_id = 1
|
||||
self.is_running = False
|
||||
|
||||
self._setup_scheduler()
|
||||
|
||||
def _setup_scheduler(self):
|
||||
"""Richtet automatische Wartungsplanung ein"""
|
||||
schedule.every().day.at("06:00").do(self._check_scheduled_maintenance)
|
||||
schedule.every().hour.do(self._check_overdue_tasks)
|
||||
schedule.every().monday.at("08:00").do(self._generate_weekly_report)
|
||||
|
||||
# Scheduler in separatem Thread
|
||||
def run_scheduler():
|
||||
while self.is_running:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # Check every minute
|
||||
|
||||
self.is_running = True
|
||||
scheduler_thread = threading.Thread(target=run_scheduler, daemon=True)
|
||||
scheduler_thread.start()
|
||||
|
||||
logger.info("Wartungs-Scheduler gestartet")
|
||||
|
||||
def create_task(self, task: MaintenanceTask) -> int:
|
||||
"""Erstellt eine neue Wartungsaufgabe"""
|
||||
task.id = self.next_task_id
|
||||
self.next_task_id += 1
|
||||
task.created_at = datetime.now()
|
||||
|
||||
self.tasks[task.id] = task
|
||||
|
||||
# Automatische Terminierung für vorbeugende Wartungen
|
||||
if task.maintenance_type == MaintenanceType.PREVENTIVE and not task.scheduled_date:
|
||||
task.scheduled_date = self._calculate_next_maintenance_date(task.printer_id)
|
||||
|
||||
# Benachrichtigungen senden
|
||||
self._send_task_notifications(task, "created")
|
||||
|
||||
logger.info(f"Wartungsaufgabe erstellt: {task.title} für Drucker {task.printer_id}")
|
||||
return task.id
|
||||
|
||||
def update_task_status(self, task_id: int, new_status: MaintenanceStatus, notes: str = "") -> bool:
|
||||
"""Aktualisiert den Status einer Wartungsaufgabe"""
|
||||
if task_id not in self.tasks:
|
||||
return False
|
||||
|
||||
task = self.tasks[task_id]
|
||||
old_status = task.status
|
||||
task.status = new_status
|
||||
|
||||
# Zeitstempel setzen
|
||||
if new_status == MaintenanceStatus.IN_PROGRESS:
|
||||
task.started_at = datetime.now()
|
||||
elif new_status == MaintenanceStatus.COMPLETED:
|
||||
task.completed_at = datetime.now()
|
||||
if task.started_at:
|
||||
task.actual_duration = int((task.completed_at - task.started_at).total_seconds() / 60)
|
||||
|
||||
# Zur Historie hinzufügen
|
||||
self.maintenance_history.append(task)
|
||||
|
||||
# Nächste Wartung planen
|
||||
self._schedule_next_maintenance(task)
|
||||
|
||||
if notes:
|
||||
task.notes += f"\n{datetime.now().strftime('%d.%m.%Y %H:%M')}: {notes}"
|
||||
|
||||
# Benachrichtigungen senden
|
||||
if old_status != new_status:
|
||||
self._send_task_notifications(task, "status_changed")
|
||||
|
||||
logger.info(f"Wartungsaufgabe {task_id} Status: {old_status.value} → {new_status.value}")
|
||||
return True
|
||||
|
||||
def schedule_maintenance(self, printer_id: int, maintenance_type: MaintenanceType,
|
||||
interval_days: int, description: str = "") -> MaintenanceSchedule:
|
||||
"""Plant regelmäßige Wartungen"""
|
||||
schedule_item = MaintenanceSchedule(
|
||||
printer_id=printer_id,
|
||||
maintenance_type=maintenance_type,
|
||||
interval_days=interval_days,
|
||||
next_due=datetime.now() + timedelta(days=interval_days),
|
||||
description=description
|
||||
)
|
||||
|
||||
if printer_id not in self.schedules:
|
||||
self.schedules[printer_id] = []
|
||||
|
||||
self.schedules[printer_id].append(schedule_item)
|
||||
|
||||
logger.info(f"Wartungsplan erstellt: {maintenance_type.value} alle {interval_days} Tage für Drucker {printer_id}")
|
||||
return schedule_item
|
||||
|
||||
def get_upcoming_maintenance(self, days_ahead: int = 7) -> List[MaintenanceTask]:
|
||||
"""Holt anstehende Wartungen"""
|
||||
cutoff_date = datetime.now() + timedelta(days=days_ahead)
|
||||
|
||||
upcoming = []
|
||||
for task in self.tasks.values():
|
||||
if (task.status in [MaintenanceStatus.PLANNED, MaintenanceStatus.SCHEDULED] and
|
||||
task.due_date and task.due_date <= cutoff_date):
|
||||
upcoming.append(task)
|
||||
|
||||
return sorted(upcoming, key=lambda t: t.due_date or datetime.max)
|
||||
|
||||
def get_overdue_tasks(self) -> List[MaintenanceTask]:
|
||||
"""Holt überfällige Wartungen"""
|
||||
now = datetime.now()
|
||||
overdue = []
|
||||
|
||||
for task in self.tasks.values():
|
||||
if (task.status in [MaintenanceStatus.PLANNED, MaintenanceStatus.SCHEDULED] and
|
||||
task.due_date and task.due_date < now):
|
||||
task.status = MaintenanceStatus.OVERDUE
|
||||
overdue.append(task)
|
||||
|
||||
return overdue
|
||||
|
||||
def get_maintenance_metrics(self, printer_id: Optional[int] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None) -> MaintenanceMetrics:
|
||||
"""Berechnet Wartungsmetriken"""
|
||||
# Filter tasks
|
||||
tasks = self.maintenance_history.copy()
|
||||
if printer_id:
|
||||
tasks = [t for t in tasks if t.printer_id == printer_id]
|
||||
if start_date:
|
||||
tasks = [t for t in tasks if t.completed_at and t.completed_at >= start_date]
|
||||
if end_date:
|
||||
tasks = [t for t in tasks if t.completed_at and t.completed_at <= end_date]
|
||||
|
||||
if not tasks:
|
||||
return MaintenanceMetrics()
|
||||
|
||||
completed_tasks = [t for t in tasks if t.status == MaintenanceStatus.COMPLETED]
|
||||
|
||||
# Grundmetriken
|
||||
total_tasks = len(tasks)
|
||||
completed_count = len(completed_tasks)
|
||||
|
||||
# Durchschnittliche Bearbeitungszeit
|
||||
completion_times = [t.actual_duration for t in completed_tasks if t.actual_duration]
|
||||
avg_completion_time = sum(completion_times) / len(completion_times) if completion_times else 0
|
||||
|
||||
# Gesamtkosten
|
||||
total_cost = sum(t.cost for t in completed_tasks if t.cost)
|
||||
|
||||
# MTBF und MTTR berechnen
|
||||
mtbf = self._calculate_mtbf(tasks, printer_id)
|
||||
mttr = avg_completion_time / 60 # Konvertiere zu Stunden
|
||||
|
||||
# Verfügbarkeit berechnen
|
||||
uptime_percentage = self._calculate_uptime(printer_id, start_date, end_date)
|
||||
|
||||
return MaintenanceMetrics(
|
||||
total_tasks=total_tasks,
|
||||
completed_tasks=completed_count,
|
||||
overdue_tasks=len(self.get_overdue_tasks()),
|
||||
average_completion_time=avg_completion_time,
|
||||
total_cost=total_cost,
|
||||
mtbf=mtbf,
|
||||
mttr=mttr,
|
||||
uptime_percentage=uptime_percentage
|
||||
)
|
||||
|
||||
def create_maintenance_checklist(self, maintenance_type: MaintenanceType) -> List[Dict[str, Any]]:
|
||||
"""Erstellt eine Wartungs-Checkliste"""
|
||||
checklists = {
|
||||
MaintenanceType.PREVENTIVE: [
|
||||
{"task": "Drucker äußerlich reinigen", "completed": False, "required": True},
|
||||
{"task": "Druckbett-Level prüfen", "completed": False, "required": True},
|
||||
{"task": "Extruder-Düse reinigen", "completed": False, "required": True},
|
||||
{"task": "Riemen-Spannung prüfen", "completed": False, "required": True},
|
||||
{"task": "Filament-Führung prüfen", "completed": False, "required": False},
|
||||
{"task": "Software-Updates prüfen", "completed": False, "required": False},
|
||||
{"task": "Lüfter reinigen", "completed": False, "required": True},
|
||||
{"task": "Schrauben nachziehen", "completed": False, "required": False}
|
||||
],
|
||||
MaintenanceType.CORRECTIVE: [
|
||||
{"task": "Problem-Diagnose durchführen", "completed": False, "required": True},
|
||||
{"task": "Defekte Teile identifizieren", "completed": False, "required": True},
|
||||
{"task": "Ersatzteile bestellen/bereitstellen", "completed": False, "required": True},
|
||||
{"task": "Reparatur durchführen", "completed": False, "required": True},
|
||||
{"task": "Funktionstest durchführen", "completed": False, "required": True},
|
||||
{"task": "Kalibrierung prüfen", "completed": False, "required": True}
|
||||
],
|
||||
MaintenanceType.INSPECTION: [
|
||||
{"task": "Sichtprüfung der Mechanik", "completed": False, "required": True},
|
||||
{"task": "Druckqualität testen", "completed": False, "required": True},
|
||||
{"task": "Temperaturen prüfen", "completed": False, "required": True},
|
||||
{"task": "Bewegungen testen", "completed": False, "required": True},
|
||||
{"task": "Verschleiß bewerten", "completed": False, "required": True}
|
||||
]
|
||||
}
|
||||
|
||||
return checklists.get(maintenance_type, [])
|
||||
|
||||
def _check_scheduled_maintenance(self):
|
||||
"""Prüft täglich auf fällige Wartungen"""
|
||||
logger.info("Prüfe fällige Wartungen...")
|
||||
|
||||
today = datetime.now()
|
||||
|
||||
for printer_id, schedules in self.schedules.items():
|
||||
for schedule_item in schedules:
|
||||
if not schedule_item.is_active:
|
||||
continue
|
||||
|
||||
if schedule_item.next_due <= today:
|
||||
# Erstelle Wartungsaufgabe
|
||||
task = MaintenanceTask(
|
||||
printer_id=printer_id,
|
||||
title=f"{schedule_item.maintenance_type.value.title()} Wartung",
|
||||
description=schedule_item.description,
|
||||
maintenance_type=schedule_item.maintenance_type,
|
||||
priority=MaintenancePriority.NORMAL,
|
||||
due_date=schedule_item.next_due,
|
||||
checklist=self.create_maintenance_checklist(schedule_item.maintenance_type)
|
||||
)
|
||||
|
||||
task_id = self.create_task(task)
|
||||
|
||||
# Nächsten Termin berechnen
|
||||
schedule_item.next_due = today + timedelta(days=schedule_item.interval_days)
|
||||
|
||||
logger.info(f"Automatische Wartungsaufgabe erstellt: {task_id}")
|
||||
|
||||
def _check_overdue_tasks(self):
|
||||
"""Prüft stündlich auf überfällige Aufgaben"""
|
||||
overdue = self.get_overdue_tasks()
|
||||
|
||||
if overdue:
|
||||
logger.warning(f"{len(overdue)} überfällige Wartungsaufgaben gefunden")
|
||||
|
||||
for task in overdue:
|
||||
emit_system_alert(
|
||||
f"Wartung überfällig: {task.title} (Drucker {task.printer_id})",
|
||||
"warning",
|
||||
"high"
|
||||
)
|
||||
|
||||
def _generate_weekly_report(self):
|
||||
"""Generiert wöchentlichen Wartungsbericht"""
|
||||
logger.info("Generiere wöchentlichen Wartungsbericht...")
|
||||
|
||||
# Sammle Daten der letzten Woche
|
||||
last_week = datetime.now() - timedelta(days=7)
|
||||
metrics = self.get_maintenance_metrics(start_date=last_week)
|
||||
|
||||
# Sende Report (Implementation abhängig von verfügbaren Services)
|
||||
# send_maintenance_report(metrics)
|
||||
|
||||
def _calculate_next_maintenance_date(self, printer_id: int) -> datetime:
|
||||
"""Berechnet nächstes Wartungsdatum basierend auf Nutzung"""
|
||||
# Vereinfachte Implementierung - kann erweitert werden
|
||||
base_interval = 30 # Tage
|
||||
|
||||
# Hier könnte man Nutzungsstatistiken einbeziehen
|
||||
with get_db_session() as db_session:
|
||||
printer = db_session.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if printer:
|
||||
# Berücksichtige letzten Check
|
||||
if printer.last_checked:
|
||||
days_since_check = (datetime.now() - printer.last_checked).days
|
||||
if days_since_check < 15: # Kürzlich gecheckt
|
||||
base_interval += 15
|
||||
|
||||
return datetime.now() + timedelta(days=base_interval)
|
||||
|
||||
def _schedule_next_maintenance(self, completed_task: MaintenanceTask):
|
||||
"""Plant nächste Wartung nach Abschluss einer Aufgabe"""
|
||||
if completed_task.maintenance_type == MaintenanceType.PREVENTIVE:
|
||||
# Finde entsprechenden Schedule
|
||||
printer_schedules = self.schedules.get(completed_task.printer_id, [])
|
||||
for schedule_item in printer_schedules:
|
||||
if schedule_item.maintenance_type == completed_task.maintenance_type:
|
||||
schedule_item.last_completed = completed_task.completed_at
|
||||
schedule_item.next_due = datetime.now() + timedelta(days=schedule_item.interval_days)
|
||||
break
|
||||
|
||||
def _calculate_mtbf(self, tasks: List[MaintenanceTask], printer_id: Optional[int]) -> float:
|
||||
"""Berechnet Mean Time Between Failures"""
|
||||
# Vereinfachte MTBF-Berechnung
|
||||
failure_tasks = [t for t in tasks if t.maintenance_type == MaintenanceType.CORRECTIVE]
|
||||
|
||||
if len(failure_tasks) < 2:
|
||||
return 0.0
|
||||
|
||||
# Zeitspanne zwischen ersten und letzten Ausfall
|
||||
first_failure = min(failure_tasks, key=lambda t: t.created_at)
|
||||
last_failure = max(failure_tasks, key=lambda t: t.created_at)
|
||||
|
||||
total_time = (last_failure.created_at - first_failure.created_at).total_seconds() / 3600 # Stunden
|
||||
failure_count = len(failure_tasks) - 1
|
||||
|
||||
return total_time / failure_count if failure_count > 0 else 0.0
|
||||
|
||||
def _calculate_uptime(self, printer_id: Optional[int], start_date: Optional[datetime],
|
||||
end_date: Optional[datetime]) -> float:
|
||||
"""Berechnet Verfügbarkeit in Prozent"""
|
||||
# Vereinfachte Uptime-Berechnung
|
||||
if not start_date:
|
||||
start_date = datetime.now() - timedelta(days=30)
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
|
||||
total_time = (end_date - start_date).total_seconds()
|
||||
|
||||
# Berechne Downtime aus Wartungszeiten
|
||||
downtime = 0
|
||||
for task in self.maintenance_history:
|
||||
if printer_id and task.printer_id != printer_id:
|
||||
continue
|
||||
|
||||
if (task.status == MaintenanceStatus.COMPLETED and
|
||||
task.started_at and task.completed_at and
|
||||
task.started_at >= start_date and task.completed_at <= end_date):
|
||||
downtime += (task.completed_at - task.started_at).total_seconds()
|
||||
|
||||
uptime = ((total_time - downtime) / total_time) * 100 if total_time > 0 else 0
|
||||
return max(0, min(100, uptime))
|
||||
|
||||
def _send_task_notifications(self, task: MaintenanceTask, event_type: str):
|
||||
"""Sendet Benachrichtigungen für Wartungsaufgaben"""
|
||||
try:
|
||||
if event_type == "created":
|
||||
emit_system_alert(
|
||||
f"Neue Wartungsaufgabe: {task.title} (Drucker {task.printer_id})",
|
||||
"info",
|
||||
"normal"
|
||||
)
|
||||
elif event_type == "status_changed":
|
||||
emit_system_alert(
|
||||
f"Wartungsstatus geändert: {task.title} → {task.status.value}",
|
||||
"info",
|
||||
"normal"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Senden der Wartungsbenachrichtigung: {str(e)}")
|
||||
|
||||
# Globale Instanz
|
||||
maintenance_manager = MaintenanceManager()
|
||||
|
||||
def get_maintenance_dashboard_data() -> Dict[str, Any]:
|
||||
"""Holt Dashboard-Daten für Wartungen"""
|
||||
upcoming = maintenance_manager.get_upcoming_maintenance()
|
||||
overdue = maintenance_manager.get_overdue_tasks()
|
||||
metrics = maintenance_manager.get_maintenance_metrics()
|
||||
|
||||
return {
|
||||
'upcoming_count': len(upcoming),
|
||||
'overdue_count': len(overdue),
|
||||
'upcoming_tasks': [asdict(task) for task in upcoming[:5]],
|
||||
'overdue_tasks': [asdict(task) for task in overdue],
|
||||
'metrics': asdict(metrics),
|
||||
'next_scheduled': upcoming[0] if upcoming else None
|
||||
}
|
||||
|
||||
def create_emergency_maintenance(printer_id: int, description: str,
|
||||
priority: MaintenancePriority = MaintenancePriority.CRITICAL) -> int:
|
||||
"""Erstellt eine Notfall-Wartung"""
|
||||
task = MaintenanceTask(
|
||||
printer_id=printer_id,
|
||||
title="Notfall-Wartung",
|
||||
description=description,
|
||||
maintenance_type=MaintenanceType.EMERGENCY,
|
||||
priority=priority,
|
||||
due_date=datetime.now(), # Sofort fällig
|
||||
checklist=maintenance_manager.create_maintenance_checklist(MaintenanceType.CORRECTIVE)
|
||||
)
|
||||
|
||||
return maintenance_manager.create_task(task)
|
||||
|
||||
def schedule_preventive_maintenance(printer_id: int, interval_days: int = 30) -> MaintenanceSchedule:
|
||||
"""Plant vorbeugende Wartung"""
|
||||
return maintenance_manager.schedule_maintenance(
|
||||
printer_id=printer_id,
|
||||
maintenance_type=MaintenanceType.PREVENTIVE,
|
||||
interval_days=interval_days,
|
||||
description="Regelmäßige vorbeugende Wartung"
|
||||
)
|
||||
|
||||
# JavaScript für Wartungs-Frontend
|
||||
def get_maintenance_javascript() -> str:
|
||||
"""JavaScript für Wartungsmanagement"""
|
||||
return """
|
||||
class MaintenanceManager {
|
||||
constructor() {
|
||||
this.currentTasks = [];
|
||||
this.selectedTask = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadTasks();
|
||||
this.setupEventListeners();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Task status updates
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('.maintenance-status-btn')) {
|
||||
const taskId = e.target.dataset.taskId;
|
||||
const newStatus = e.target.dataset.status;
|
||||
this.updateTaskStatus(taskId, newStatus);
|
||||
}
|
||||
|
||||
if (e.target.matches('.maintenance-details-btn')) {
|
||||
const taskId = e.target.dataset.taskId;
|
||||
this.showTaskDetails(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
// Create maintenance form
|
||||
const createForm = document.getElementById('create-maintenance-form');
|
||||
createForm?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.createTask(new FormData(createForm));
|
||||
});
|
||||
}
|
||||
|
||||
async loadTasks() {
|
||||
try {
|
||||
const response = await fetch('/api/maintenance/tasks');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.currentTasks = data.tasks;
|
||||
this.renderTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Wartungsaufgaben:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskStatus(taskId, newStatus) {
|
||||
try {
|
||||
const response = await fetch(`/api/maintenance/tasks/${taskId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.loadTasks(); // Refresh
|
||||
this.showNotification('Wartungsstatus aktualisiert', 'success');
|
||||
} else {
|
||||
this.showNotification('Fehler beim Aktualisieren', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Status-Update fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderTasks() {
|
||||
const container = document.getElementById('maintenance-tasks-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = this.currentTasks.map(task => `
|
||||
<div class="maintenance-task-card ${task.status} priority-${task.priority}">
|
||||
<div class="task-header">
|
||||
<h3>${task.title}</h3>
|
||||
<span class="task-priority">${task.priority}</span>
|
||||
</div>
|
||||
<div class="task-info">
|
||||
<p><strong>Drucker:</strong> ${task.printer_id}</p>
|
||||
<p><strong>Typ:</strong> ${task.maintenance_type}</p>
|
||||
<p><strong>Fällig:</strong> ${this.formatDate(task.due_date)}</p>
|
||||
<p><strong>Status:</strong> ${task.status}</p>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="maintenance-status-btn" data-task-id="${task.id}" data-status="in_progress">
|
||||
Starten
|
||||
</button>
|
||||
<button class="maintenance-status-btn" data-task-id="${task.id}" data-status="completed">
|
||||
Abschließen
|
||||
</button>
|
||||
<button class="maintenance-details-btn" data-task-id="${task.id}">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
showTaskDetails(taskId) {
|
||||
const task = this.currentTasks.find(t => t.id == taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Create modal with task details
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'maintenance-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>${task.title}</h2>
|
||||
<button class="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="task-details">
|
||||
<p><strong>Beschreibung:</strong> ${task.description}</p>
|
||||
<p><strong>Techniker:</strong> ${task.assigned_technician || 'Nicht zugewiesen'}</p>
|
||||
<p><strong>Geschätzte Dauer:</strong> ${task.estimated_duration} Minuten</p>
|
||||
|
||||
${task.checklist ? this.renderChecklist(task.checklist) : ''}
|
||||
|
||||
<div class="task-notes">
|
||||
<h4>Notizen:</h4>
|
||||
<textarea id="task-notes-${taskId}" rows="4" cols="50">${task.notes || ''}</textarea>
|
||||
<button onclick="maintenanceManager.saveNotes(${taskId})">Notizen speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Close modal handlers
|
||||
modal.querySelector('.close-modal').onclick = () => modal.remove();
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
}
|
||||
|
||||
renderChecklist(checklist) {
|
||||
return `
|
||||
<div class="maintenance-checklist">
|
||||
<h4>Checkliste:</h4>
|
||||
${checklist.map((item, index) => `
|
||||
<label class="checklist-item">
|
||||
<input type="checkbox" ${item.completed ? 'checked' : ''}
|
||||
onchange="maintenanceManager.updateChecklistItem(${index}, this.checked)">
|
||||
${item.task}
|
||||
${item.required ? '<span class="required">*</span>' : ''}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'Nicht gesetzt';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE') + ' ' + date.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
setInterval(() => {
|
||||
this.loadTasks();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.maintenanceManager = new MaintenanceManager();
|
||||
});
|
||||
"""
|
784
backend/utils/multi_location_system.py
Normal file
784
backend/utils/multi_location_system.py
Normal file
@ -0,0 +1,784 @@
|
||||
"""
|
||||
Multi-Standort-Unterstützungssystem für das MYP-System
|
||||
======================================================
|
||||
|
||||
Dieses Modul stellt umfassende Multi-Location-Funktionalität bereit:
|
||||
- Standort-Management und Hierarchien
|
||||
- Standort-spezifische Konfigurationen
|
||||
- Zentrale und dezentrale Verwaltung
|
||||
- Standort-übergreifende Berichte
|
||||
- Ressourcen-Sharing zwischen Standorten
|
||||
- Benutzer-Standort-Zuweisungen
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
import geocoder
|
||||
import requests
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import User, Printer, Job, get_db_session
|
||||
|
||||
logger = get_logger("multi_location")
|
||||
|
||||
class LocationType(Enum):
|
||||
"""Arten von Standorten"""
|
||||
HEADQUARTERS = "headquarters" # Hauptsitz
|
||||
BRANCH = "branch" # Niederlassung
|
||||
DEPARTMENT = "department" # Abteilung
|
||||
FLOOR = "floor" # Stockwerk
|
||||
ROOM = "room" # Raum
|
||||
AREA = "area" # Bereich
|
||||
|
||||
class AccessLevel(Enum):
|
||||
"""Zugriffslevel für Standorte"""
|
||||
FULL = "full" # Vollzugriff
|
||||
READ_WRITE = "read_write" # Lesen und Schreiben
|
||||
READ_ONLY = "read_only" # Nur Lesen
|
||||
NO_ACCESS = "no_access" # Kein Zugriff
|
||||
|
||||
@dataclass
|
||||
class LocationConfig:
|
||||
"""Standort-spezifische Konfiguration"""
|
||||
timezone: str = "Europe/Berlin"
|
||||
business_hours: Dict[str, str] = None
|
||||
maintenance_window: Dict[str, str] = None
|
||||
auto_approval_enabled: bool = False
|
||||
max_job_duration: int = 480 # Minuten
|
||||
contact_info: Dict[str, str] = None
|
||||
notification_settings: Dict[str, Any] = None
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""Standort-Definition"""
|
||||
id: Optional[int] = None
|
||||
name: str = ""
|
||||
code: str = "" # Kurzer Code für den Standort
|
||||
location_type: LocationType = LocationType.BRANCH
|
||||
parent_id: Optional[int] = None
|
||||
address: str = ""
|
||||
city: str = ""
|
||||
country: str = ""
|
||||
postal_code: str = ""
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
description: str = ""
|
||||
config: LocationConfig = None
|
||||
is_active: bool = True
|
||||
created_at: datetime = None
|
||||
manager_id: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.config is None:
|
||||
self.config = LocationConfig()
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now()
|
||||
|
||||
@dataclass
|
||||
class UserLocationAccess:
|
||||
"""Benutzer-Standort-Zugriff"""
|
||||
user_id: int
|
||||
location_id: int
|
||||
access_level: AccessLevel
|
||||
granted_by: int
|
||||
granted_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
is_primary: bool = False
|
||||
|
||||
class MultiLocationManager:
|
||||
"""Manager für Multi-Standort-Funktionalität"""
|
||||
|
||||
def __init__(self):
|
||||
self.locations: Dict[int, Location] = {}
|
||||
self.user_access: Dict[int, List[UserLocationAccess]] = {}
|
||||
self.next_location_id = 1
|
||||
|
||||
# Standard-Standort erstellen
|
||||
self._create_default_location()
|
||||
|
||||
def _create_default_location(self):
|
||||
"""Erstellt Standard-Standort falls keiner existiert"""
|
||||
default_location = Location(
|
||||
id=1,
|
||||
name="Hauptstandort",
|
||||
code="HQ",
|
||||
location_type=LocationType.HEADQUARTERS,
|
||||
address="Mercedes-Benz Platz",
|
||||
city="Stuttgart",
|
||||
country="Deutschland",
|
||||
description="Hauptstandort des MYP-Systems"
|
||||
)
|
||||
|
||||
self.locations[1] = default_location
|
||||
self.next_location_id = 2
|
||||
|
||||
logger.info("Standard-Standort erstellt")
|
||||
|
||||
def create_location(self, location: Location) -> int:
|
||||
"""Erstellt einen neuen Standort"""
|
||||
location.id = self.next_location_id
|
||||
self.next_location_id += 1
|
||||
|
||||
# Koordinaten automatisch ermitteln
|
||||
if not location.latitude or not location.longitude:
|
||||
self._geocode_location(location)
|
||||
|
||||
self.locations[location.id] = location
|
||||
|
||||
logger.info(f"Standort erstellt: {location.name} ({location.code})")
|
||||
return location.id
|
||||
|
||||
def update_location(self, location_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Aktualisiert einen Standort"""
|
||||
if location_id not in self.locations:
|
||||
return False
|
||||
|
||||
location = self.locations[location_id]
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(location, key):
|
||||
setattr(location, key, value)
|
||||
|
||||
# Koordinaten neu ermitteln bei Adressänderung
|
||||
if 'address' in updates or 'city' in updates:
|
||||
self._geocode_location(location)
|
||||
|
||||
logger.info(f"Standort aktualisiert: {location.name}")
|
||||
return True
|
||||
|
||||
def delete_location(self, location_id: int) -> bool:
|
||||
"""Löscht einen Standort (Soft Delete)"""
|
||||
if location_id not in self.locations:
|
||||
return False
|
||||
|
||||
location = self.locations[location_id]
|
||||
|
||||
# Prüfe ob Standort Kinder hat
|
||||
children = self.get_child_locations(location_id)
|
||||
if children:
|
||||
logger.warning(f"Standort {location.name} kann nicht gelöscht werden: hat Unterstandorte")
|
||||
return False
|
||||
|
||||
# Prüfe auf aktive Ressourcen
|
||||
if self._has_active_resources(location_id):
|
||||
logger.warning(f"Standort {location.name} kann nicht gelöscht werden: hat aktive Ressourcen")
|
||||
return False
|
||||
|
||||
location.is_active = False
|
||||
logger.info(f"Standort deaktiviert: {location.name}")
|
||||
return True
|
||||
|
||||
def get_location_hierarchy(self, location_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Holt Standort-Hierarchie"""
|
||||
if location_id:
|
||||
# Spezifische Hierarchie ab einem Standort
|
||||
location = self.locations.get(location_id)
|
||||
if not location:
|
||||
return {}
|
||||
|
||||
return self._build_hierarchy_node(location)
|
||||
else:
|
||||
# Komplette Hierarchie
|
||||
root_locations = [loc for loc in self.locations.values()
|
||||
if loc.parent_id is None and loc.is_active]
|
||||
|
||||
return {
|
||||
'locations': [self._build_hierarchy_node(loc) for loc in root_locations]
|
||||
}
|
||||
|
||||
def _build_hierarchy_node(self, location: Location) -> Dict[str, Any]:
|
||||
"""Erstellt einen Hierarchie-Knoten"""
|
||||
children = self.get_child_locations(location.id)
|
||||
|
||||
return {
|
||||
'id': location.id,
|
||||
'name': location.name,
|
||||
'code': location.code,
|
||||
'type': location.location_type.value,
|
||||
'children': [self._build_hierarchy_node(child) for child in children],
|
||||
'resource_count': self._count_location_resources(location.id)
|
||||
}
|
||||
|
||||
def get_child_locations(self, parent_id: int) -> List[Location]:
|
||||
"""Holt alle Kinder-Standorte"""
|
||||
return [loc for loc in self.locations.values()
|
||||
if loc.parent_id == parent_id and loc.is_active]
|
||||
|
||||
def get_location_path(self, location_id: int) -> List[Location]:
|
||||
"""Holt den Pfad vom Root zum Standort"""
|
||||
path = []
|
||||
current_id = location_id
|
||||
|
||||
while current_id:
|
||||
location = self.locations.get(current_id)
|
||||
if not location:
|
||||
break
|
||||
|
||||
path.insert(0, location)
|
||||
current_id = location.parent_id
|
||||
|
||||
return path
|
||||
|
||||
def grant_location_access(self, user_id: int, location_id: int,
|
||||
access_level: AccessLevel, granted_by: int,
|
||||
expires_at: Optional[datetime] = None,
|
||||
is_primary: bool = False) -> bool:
|
||||
"""Gewährt Benutzer-Zugriff auf einen Standort"""
|
||||
if location_id not in self.locations:
|
||||
return False
|
||||
|
||||
access = UserLocationAccess(
|
||||
user_id=user_id,
|
||||
location_id=location_id,
|
||||
access_level=access_level,
|
||||
granted_by=granted_by,
|
||||
granted_at=datetime.now(),
|
||||
expires_at=expires_at,
|
||||
is_primary=is_primary
|
||||
)
|
||||
|
||||
if user_id not in self.user_access:
|
||||
self.user_access[user_id] = []
|
||||
|
||||
# Entferne vorherigen Zugriff für diesen Standort
|
||||
self.user_access[user_id] = [
|
||||
acc for acc in self.user_access[user_id]
|
||||
if acc.location_id != location_id
|
||||
]
|
||||
|
||||
# Setze anderen primary-Zugriff zurück falls nötig
|
||||
if is_primary:
|
||||
for access_item in self.user_access[user_id]:
|
||||
access_item.is_primary = False
|
||||
|
||||
self.user_access[user_id].append(access)
|
||||
|
||||
logger.info(f"Standort-Zugriff gewährt: User {user_id} → Location {location_id} ({access_level.value})")
|
||||
return True
|
||||
|
||||
def revoke_location_access(self, user_id: int, location_id: int) -> bool:
|
||||
"""Entzieht Benutzer-Zugriff auf einen Standort"""
|
||||
if user_id not in self.user_access:
|
||||
return False
|
||||
|
||||
original_count = len(self.user_access[user_id])
|
||||
self.user_access[user_id] = [
|
||||
acc for acc in self.user_access[user_id]
|
||||
if acc.location_id != location_id
|
||||
]
|
||||
|
||||
success = len(self.user_access[user_id]) < original_count
|
||||
if success:
|
||||
logger.info(f"Standort-Zugriff entzogen: User {user_id} → Location {location_id}")
|
||||
|
||||
return success
|
||||
|
||||
def get_user_locations(self, user_id: int, access_level: Optional[AccessLevel] = None) -> List[Location]:
|
||||
"""Holt alle Standorte eines Benutzers"""
|
||||
if user_id not in self.user_access:
|
||||
return []
|
||||
|
||||
accessible_locations = []
|
||||
now = datetime.now()
|
||||
|
||||
for access in self.user_access[user_id]:
|
||||
# Prüfe Ablaufzeit
|
||||
if access.expires_at and access.expires_at < now:
|
||||
continue
|
||||
|
||||
# Prüfe Access Level
|
||||
if access_level and access.access_level != access_level:
|
||||
continue
|
||||
|
||||
location = self.locations.get(access.location_id)
|
||||
if location and location.is_active:
|
||||
accessible_locations.append(location)
|
||||
|
||||
return accessible_locations
|
||||
|
||||
def get_user_primary_location(self, user_id: int) -> Optional[Location]:
|
||||
"""Holt den primären Standort eines Benutzers"""
|
||||
if user_id not in self.user_access:
|
||||
return None
|
||||
|
||||
for access in self.user_access[user_id]:
|
||||
if access.is_primary:
|
||||
return self.locations.get(access.location_id)
|
||||
|
||||
# Fallback: ersten verfügbaren Standort nehmen
|
||||
user_locations = self.get_user_locations(user_id)
|
||||
return user_locations[0] if user_locations else None
|
||||
|
||||
def check_user_access(self, user_id: int, location_id: int,
|
||||
required_level: AccessLevel = AccessLevel.READ_ONLY) -> bool:
|
||||
"""Prüft ob Benutzer Zugriff auf Standort hat"""
|
||||
if user_id not in self.user_access:
|
||||
return False
|
||||
|
||||
access_levels = {
|
||||
AccessLevel.NO_ACCESS: 0,
|
||||
AccessLevel.READ_ONLY: 1,
|
||||
AccessLevel.READ_WRITE: 2,
|
||||
AccessLevel.FULL: 3
|
||||
}
|
||||
|
||||
required_level_value = access_levels[required_level]
|
||||
now = datetime.now()
|
||||
|
||||
for access in self.user_access[user_id]:
|
||||
if access.location_id != location_id:
|
||||
continue
|
||||
|
||||
# Prüfe Ablaufzeit
|
||||
if access.expires_at and access.expires_at < now:
|
||||
continue
|
||||
|
||||
user_level_value = access_levels[access.access_level]
|
||||
if user_level_value >= required_level_value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_location_resources(self, location_id: int) -> Dict[str, Any]:
|
||||
"""Holt alle Ressourcen eines Standorts"""
|
||||
if location_id not in self.locations:
|
||||
return {}
|
||||
|
||||
# Simuliere Datenbankabfrage für Drucker und Jobs
|
||||
resources = {
|
||||
'printers': [],
|
||||
'active_jobs': [],
|
||||
'users': [],
|
||||
'pending_maintenance': 0
|
||||
}
|
||||
|
||||
# In echter Implementierung würde hier die Datenbank abgefragt
|
||||
with get_db_session() as db_session:
|
||||
# Drucker des Standorts (vereinfacht - benötigt location_id in Printer-Model)
|
||||
# printers = db_session.query(Printer).filter(Printer.location_id == location_id).all()
|
||||
# resources['printers'] = [p.to_dict() for p in printers]
|
||||
pass
|
||||
|
||||
return resources
|
||||
|
||||
def get_location_statistics(self, location_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None) -> Dict[str, Any]:
|
||||
"""Holt Statistiken für einen Standort"""
|
||||
if not start_date:
|
||||
start_date = datetime.now() - timedelta(days=30)
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
|
||||
# Sammle Statistiken
|
||||
stats = {
|
||||
'location': self.locations.get(location_id, {}).name if location_id in self.locations else 'Unbekannt',
|
||||
'period': {
|
||||
'start': start_date.isoformat(),
|
||||
'end': end_date.isoformat()
|
||||
},
|
||||
'totals': {
|
||||
'printers': 0,
|
||||
'jobs_completed': 0,
|
||||
'jobs_failed': 0,
|
||||
'print_time_hours': 0,
|
||||
'material_used_kg': 0,
|
||||
'users_active': 0
|
||||
},
|
||||
'averages': {
|
||||
'jobs_per_day': 0,
|
||||
'job_duration_minutes': 0,
|
||||
'printer_utilization': 0
|
||||
},
|
||||
'trends': {
|
||||
'daily_jobs': [],
|
||||
'printer_usage': []
|
||||
}
|
||||
}
|
||||
|
||||
# In echter Implementierung würden hier Datenbankabfragen stehen
|
||||
|
||||
return stats
|
||||
|
||||
def get_multi_location_report(self, location_ids: List[int] = None) -> Dict[str, Any]:
|
||||
"""Erstellt standortübergreifenden Bericht"""
|
||||
if not location_ids:
|
||||
location_ids = list(self.locations.keys())
|
||||
|
||||
report = {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'locations': [],
|
||||
'summary': {
|
||||
'total_locations': len(location_ids),
|
||||
'total_printers': 0,
|
||||
'total_users': 0,
|
||||
'total_jobs': 0,
|
||||
'cross_location_sharing': []
|
||||
}
|
||||
}
|
||||
|
||||
for location_id in location_ids:
|
||||
location = self.locations.get(location_id)
|
||||
if not location:
|
||||
continue
|
||||
|
||||
location_stats = self.get_location_statistics(location_id)
|
||||
location_data = {
|
||||
'id': location.id,
|
||||
'name': location.name,
|
||||
'code': location.code,
|
||||
'type': location.location_type.value,
|
||||
'statistics': location_stats
|
||||
}
|
||||
|
||||
report['locations'].append(location_data)
|
||||
|
||||
# Summiere für Gesamtübersicht
|
||||
totals = location_stats.get('totals', {})
|
||||
report['summary']['total_printers'] += totals.get('printers', 0)
|
||||
report['summary']['total_users'] += totals.get('users_active', 0)
|
||||
report['summary']['total_jobs'] += totals.get('jobs_completed', 0)
|
||||
|
||||
return report
|
||||
|
||||
def find_nearest_locations(self, latitude: float, longitude: float,
|
||||
radius_km: float = 50, limit: int = 5) -> List[Tuple[Location, float]]:
|
||||
"""Findet nächstgelegene Standorte"""
|
||||
from math import radians, sin, cos, sqrt, atan2
|
||||
|
||||
def calculate_distance(lat1, lon1, lat2, lon2):
|
||||
"""Berechnet Entfernung zwischen zwei Koordinaten (Haversine)"""
|
||||
R = 6371 # Erdradius in km
|
||||
|
||||
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
||||
c = 2 * atan2(sqrt(a), sqrt(1-a))
|
||||
|
||||
return R * c
|
||||
|
||||
nearby_locations = []
|
||||
|
||||
for location in self.locations.values():
|
||||
if not location.is_active or not location.latitude or not location.longitude:
|
||||
continue
|
||||
|
||||
distance = calculate_distance(
|
||||
latitude, longitude,
|
||||
location.latitude, location.longitude
|
||||
)
|
||||
|
||||
if distance <= radius_km:
|
||||
nearby_locations.append((location, distance))
|
||||
|
||||
# Sortiere nach Entfernung
|
||||
nearby_locations.sort(key=lambda x: x[1])
|
||||
|
||||
return nearby_locations[:limit]
|
||||
|
||||
def _geocode_location(self, location: Location):
|
||||
"""Ermittelt Koordinaten für einen Standort"""
|
||||
try:
|
||||
address_parts = [location.address, location.city, location.country]
|
||||
full_address = ', '.join(filter(None, address_parts))
|
||||
|
||||
if not full_address:
|
||||
return
|
||||
|
||||
# Verwende geocoder library
|
||||
result = geocoder.osm(full_address)
|
||||
|
||||
if result.ok:
|
||||
location.latitude = result.lat
|
||||
location.longitude = result.lng
|
||||
logger.info(f"Koordinaten ermittelt für {location.name}: {location.latitude}, {location.longitude}")
|
||||
else:
|
||||
logger.warning(f"Koordinaten konnten nicht ermittelt werden für {location.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Geocoding für {location.name}: {str(e)}")
|
||||
|
||||
def _has_active_resources(self, location_id: int) -> bool:
|
||||
"""Prüft ob Standort aktive Ressourcen hat"""
|
||||
# Vereinfachte Implementierung
|
||||
# In echter Implementation würde hier die Datenbank geprüft
|
||||
return False
|
||||
|
||||
def _count_location_resources(self, location_id: int) -> Dict[str, int]:
|
||||
"""Zählt Ressourcen eines Standorts"""
|
||||
# Vereinfachte Implementierung
|
||||
return {
|
||||
'printers': 0,
|
||||
'users': 0,
|
||||
'jobs': 0
|
||||
}
|
||||
|
||||
# Globale Instanz
|
||||
multi_location_manager = MultiLocationManager()
|
||||
|
||||
def get_location_dashboard_data(user_id: int) -> Dict[str, Any]:
|
||||
"""Holt Dashboard-Daten für Standorte eines Benutzers"""
|
||||
user_locations = multi_location_manager.get_user_locations(user_id)
|
||||
primary_location = multi_location_manager.get_user_primary_location(user_id)
|
||||
|
||||
dashboard_data = {
|
||||
'user_locations': [asdict(loc) for loc in user_locations],
|
||||
'primary_location': asdict(primary_location) if primary_location else None,
|
||||
'location_count': len(user_locations),
|
||||
'hierarchy': multi_location_manager.get_location_hierarchy()
|
||||
}
|
||||
|
||||
# Füge Statistiken für jeden Standort hinzu
|
||||
for location in user_locations:
|
||||
location_stats = multi_location_manager.get_location_statistics(location.id)
|
||||
dashboard_data[f'stats_{location.id}'] = location_stats
|
||||
|
||||
return dashboard_data
|
||||
|
||||
def create_location_from_address(name: str, address: str, city: str,
|
||||
country: str, location_type: LocationType = LocationType.BRANCH) -> int:
|
||||
"""Erstellt Standort aus Adresse mit automatischer Geocodierung"""
|
||||
location = Location(
|
||||
name=name,
|
||||
code=name[:3].upper(),
|
||||
location_type=location_type,
|
||||
address=address,
|
||||
city=city,
|
||||
country=country
|
||||
)
|
||||
|
||||
return multi_location_manager.create_location(location)
|
||||
|
||||
# JavaScript für Multi-Location Frontend
|
||||
def get_multi_location_javascript() -> str:
|
||||
"""JavaScript für Multi-Location Management"""
|
||||
return """
|
||||
class MultiLocationManager {
|
||||
constructor() {
|
||||
this.currentLocation = null;
|
||||
this.userLocations = [];
|
||||
this.locationHierarchy = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadUserLocations();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Location switcher
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.matches('.location-selector')) {
|
||||
const locationId = parseInt(e.target.value);
|
||||
this.switchLocation(locationId);
|
||||
}
|
||||
});
|
||||
|
||||
// Location management buttons
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('.manage-locations-btn')) {
|
||||
this.showLocationManager();
|
||||
}
|
||||
|
||||
if (e.target.matches('.location-hierarchy-btn')) {
|
||||
this.showLocationHierarchy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadUserLocations() {
|
||||
try {
|
||||
const response = await fetch('/api/locations/user');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.userLocations = data.locations;
|
||||
this.currentLocation = data.primary_location;
|
||||
this.locationHierarchy = data.hierarchy;
|
||||
|
||||
this.updateLocationSelector();
|
||||
this.updateLocationDisplay();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Standorte:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateLocationSelector() {
|
||||
const selectors = document.querySelectorAll('.location-selector');
|
||||
|
||||
selectors.forEach(selector => {
|
||||
selector.innerHTML = this.userLocations.map(location =>
|
||||
`<option value="${location.id}" ${location.id === this.currentLocation?.id ? 'selected' : ''}>
|
||||
${location.name} (${location.code})
|
||||
</option>`
|
||||
).join('');
|
||||
});
|
||||
}
|
||||
|
||||
updateLocationDisplay() {
|
||||
const displays = document.querySelectorAll('.current-location-display');
|
||||
|
||||
displays.forEach(display => {
|
||||
if (this.currentLocation) {
|
||||
display.innerHTML = `
|
||||
<div class="location-info">
|
||||
<strong>${this.currentLocation.name}</strong>
|
||||
<span class="location-type">${this.currentLocation.type}</span>
|
||||
${this.currentLocation.city ? `<span class="location-city">${this.currentLocation.city}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
display.innerHTML = '<span class="no-location">Kein Standort ausgewählt</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async switchLocation(locationId) {
|
||||
try {
|
||||
const response = await fetch('/api/locations/switch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ location_id: locationId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.currentLocation = this.userLocations.find(loc => loc.id === locationId);
|
||||
this.updateLocationDisplay();
|
||||
|
||||
// Seite neu laden um location-spezifische Daten zu aktualisieren
|
||||
window.location.reload();
|
||||
} else {
|
||||
this.showNotification('Fehler beim Wechseln des Standorts', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Standort-Wechsel fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showLocationManager() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'location-manager-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Standort-Verwaltung</h2>
|
||||
<button class="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="location-list">
|
||||
${this.renderLocationList()}
|
||||
</div>
|
||||
<div class="location-actions">
|
||||
<button class="btn-create-location">Neuen Standort erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Event handlers
|
||||
modal.querySelector('.close-modal').onclick = () => modal.remove();
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
}
|
||||
|
||||
renderLocationList() {
|
||||
return this.userLocations.map(location => `
|
||||
<div class="location-item">
|
||||
<div class="location-details">
|
||||
<h4>${location.name} (${location.code})</h4>
|
||||
<p><strong>Typ:</strong> ${location.type}</p>
|
||||
<p><strong>Adresse:</strong> ${location.address || 'Nicht angegeben'}</p>
|
||||
<p><strong>Stadt:</strong> ${location.city || 'Nicht angegeben'}</p>
|
||||
</div>
|
||||
<div class="location-actions">
|
||||
<button class="btn-edit-location" data-location-id="${location.id}">Bearbeiten</button>
|
||||
<button class="btn-view-stats" data-location-id="${location.id}">Statistiken</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
showLocationHierarchy() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'hierarchy-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Standort-Hierarchie</h2>
|
||||
<button class="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="hierarchy-tree">
|
||||
${this.renderHierarchyTree(this.locationHierarchy.locations || [])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelector('.close-modal').onclick = () => modal.remove();
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
};
|
||||
}
|
||||
|
||||
renderHierarchyTree(locations, level = 0) {
|
||||
return locations.map(location => `
|
||||
<div class="hierarchy-node" style="margin-left: ${level * 20}px;">
|
||||
<div class="node-content">
|
||||
<span class="node-icon">${this.getLocationTypeIcon(location.type)}</span>
|
||||
<span class="node-name">${location.name}</span>
|
||||
<span class="node-code">(${location.code})</span>
|
||||
<span class="resource-count">${location.resource_count.printers || 0} Drucker</span>
|
||||
</div>
|
||||
${location.children && location.children.length > 0 ?
|
||||
this.renderHierarchyTree(location.children, level + 1) : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
getLocationTypeIcon(type) {
|
||||
const icons = {
|
||||
'headquarters': '🏢',
|
||||
'branch': '🏪',
|
||||
'department': '🏬',
|
||||
'floor': '🏢',
|
||||
'room': '🚪',
|
||||
'area': '📍'
|
||||
};
|
||||
return icons[type] || '📍';
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.multiLocationManager = new MultiLocationManager();
|
||||
});
|
||||
"""
|
Loading…
x
Reference in New Issue
Block a user