🎉 Refactor & Update Backend Files, Documentation 📚

This commit is contained in:
Till Tomczak 2025-06-01 00:05:09 +02:00
parent 193164964e
commit b2bdc2d123
12 changed files with 4178 additions and 1868 deletions

1
backend/README.md Normal file
View File

@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

View File

@ -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')

View File

@ -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
# =============================================================================

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -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:

View 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 %}

View File

@ -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()

View 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

View 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">&times;</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();
});
"""

View 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">&times;</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">&times;</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();
});
"""