2025-06-04 10:03:22 +02:00

612 lines
23 KiB
Python

"""
Jobs Blueprint - API-Endpunkte für Job-Verwaltung
Alle Job-bezogenen API-Endpunkte sind hier zentralisiert.
"""
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from datetime import datetime, timedelta
from functools import wraps
from sqlalchemy.orm import joinedload
from models import get_db_session, Job, Printer
from utils.logging_config import get_logger
from utils.conflict_manager import conflict_manager
# Blueprint initialisieren - URL-Präfix geändert um Konflikte zu vermeiden
jobs_blueprint = Blueprint('jobs', __name__, url_prefix='/api/jobs-bp')
# Logger für Jobs
jobs_logger = get_logger("jobs")
def job_owner_required(f):
"""Decorator um zu prüfen, ob der aktuelle Benutzer Besitzer eines Jobs ist oder Admin"""
@wraps(f)
def decorated_function(job_id, *args, **kwargs):
db_session = get_db_session()
job = db_session.query(Job).filter(Job.id == job_id).first()
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
is_owner = job.user_id == int(current_user.id) or job.owner_id == int(current_user.id)
is_admin = current_user.is_admin
if not (is_owner or is_admin):
db_session.close()
return jsonify({"error": "Keine Berechtigung"}), 403
db_session.close()
return f(job_id, *args, **kwargs)
return decorated_function
def check_printer_status(ip_address: str, timeout: int = 7):
"""Mock-Implementierung für Drucker-Status-Check"""
# TODO: Implementiere echten Status-Check
if ip_address:
return "online", True
return "offline", False
@jobs_blueprint.route('', methods=['GET'])
@login_required
def get_jobs():
"""Gibt alle Jobs zurück. Admins sehen alle Jobs, normale Benutzer nur ihre eigenen."""
db_session = get_db_session()
try:
jobs_logger.info(f"📋 Jobs-Abfrage gestartet von Benutzer {current_user.id} (Admin: {current_user.is_admin})")
# Paginierung unterstützen
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
status_filter = request.args.get('status')
jobs_logger.debug(f"📋 Parameter: page={page}, per_page={per_page}, status_filter={status_filter}")
# Query aufbauen
query = db_session.query(Job).options(joinedload(Job.user), joinedload(Job.printer))
# Admin sieht alle Jobs, User nur eigene
if not current_user.is_admin:
query = query.filter(Job.user_id == int(current_user.id))
jobs_logger.debug(f"🔒 Benutzerfilter angewendet für User {current_user.id}")
# Status-Filter anwenden
if status_filter:
query = query.filter(Job.status == status_filter)
jobs_logger.debug(f"🏷️ Status-Filter angewendet: {status_filter}")
# Sortierung: neueste zuerst
query = query.order_by(Job.created_at.desc())
# Paginierung anwenden
offset = (page - 1) * per_page
jobs = query.offset(offset).limit(per_page).all()
# Gesamtanzahl für Paginierung
total_count = query.count()
# Convert jobs to dictionaries before closing the session
job_dicts = [job.to_dict() for job in jobs]
db_session.close()
jobs_logger.info(f"✅ Jobs erfolgreich abgerufen: {len(job_dicts)} von {total_count} (Seite {page})")
return jsonify({
"jobs": job_dicts,
"pagination": {
"page": page,
"per_page": per_page,
"total": total_count,
"pages": (total_count + per_page - 1) // per_page
}
})
except Exception as e:
jobs_logger.error(f"❌ Fehler beim Abrufen von Jobs: {str(e)}", exc_info=True)
try:
db_session.close()
except:
pass
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>', methods=['GET'])
@login_required
@job_owner_required
def get_job(job_id):
"""Gibt einen einzelnen Job zurück."""
db_session = get_db_session()
try:
jobs_logger.info(f"🔍 Job-Detail-Abfrage für Job {job_id} von Benutzer {current_user.id}")
# Eagerly load the user and printer relationships
job = db_session.query(Job).options(joinedload(Job.user), joinedload(Job.printer)).filter(Job.id == job_id).first()
if not job:
jobs_logger.warning(f"⚠️ Job {job_id} nicht gefunden")
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Convert to dict before closing session
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"✅ Job-Details erfolgreich abgerufen für Job {job_id}")
return jsonify(job_dict)
except Exception as e:
jobs_logger.error(f"❌ Fehler beim Abrufen des Jobs {job_id}: {str(e)}", exc_info=True)
try:
db_session.close()
except:
pass
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('', methods=['POST'])
@login_required
def create_job():
"""
Erstellt einen neuen Job.
Body: {
"name": str (optional),
"description": str (optional),
"printer_id": int,
"start_iso": str,
"duration_minutes": int,
"file_path": str (optional)
}
"""
try:
jobs_logger.info(f"🚀 Neue Job-Erstellung gestartet von Benutzer {current_user.id}")
data = request.json
if not data:
jobs_logger.error("❌ Keine JSON-Daten empfangen")
return jsonify({"error": "Keine JSON-Daten empfangen"}), 400
jobs_logger.debug(f"📋 Empfangene Daten: {data}")
# Pflichtfelder prüfen
required_fields = ["printer_id", "start_iso", "duration_minutes"]
for field in required_fields:
if field not in data:
jobs_logger.error(f"❌ Pflichtfeld '{field}' fehlt in den Daten")
return jsonify({"error": f"Feld '{field}' fehlt"}), 400
# Daten extrahieren und validieren
try:
printer_id = int(data["printer_id"])
start_iso = data["start_iso"]
duration_minutes = int(data["duration_minutes"])
jobs_logger.debug(f"✅ Grunddaten validiert: printer_id={printer_id}, duration={duration_minutes}")
except (ValueError, TypeError) as e:
jobs_logger.error(f"❌ Fehler bei Datenvalidierung: {str(e)}")
return jsonify({"error": f"Ungültige Datenformate: {str(e)}"}), 400
# Optional: Jobtitel, Beschreibung und Dateipfad
name = data.get("name", f"Druckjob vom {datetime.now().strftime('%d.%m.%Y %H:%M')}")
description = data.get("description", "")
file_path = data.get("file_path")
# Start-Zeit parsen
try:
start_at = datetime.fromisoformat(start_iso.replace('Z', '+00:00'))
jobs_logger.debug(f"✅ Startzeit geparst: {start_at}")
except ValueError as e:
jobs_logger.error(f"❌ Ungültiges Startdatum '{start_iso}': {str(e)}")
return jsonify({"error": f"Ungültiges Startdatum: {str(e)}"}), 400
# Dauer validieren
if duration_minutes <= 0:
jobs_logger.error(f"❌ Ungültige Dauer: {duration_minutes} Minuten")
return jsonify({"error": "Dauer muss größer als 0 sein"}), 400
# End-Zeit berechnen
end_at = start_at + timedelta(minutes=duration_minutes)
db_session = get_db_session()
try:
# Prüfen, ob der Drucker existiert
printer = db_session.query(Printer).get(printer_id)
if not printer:
jobs_logger.error(f"❌ Drucker mit ID {printer_id} nicht gefunden")
db_session.close()
return jsonify({"error": "Drucker nicht gefunden"}), 404
jobs_logger.debug(f"✅ Drucker gefunden: {printer.name} (ID: {printer_id})")
# ERWEITERTE KONFLIKTPRÜFUNG
job_data = {
'printer_id': printer_id,
'start_time': start_at,
'end_time': end_at,
'priority': data.get('priority', 'normal'),
'duration_minutes': duration_minutes
}
# Konflikte erkennen
conflicts = conflict_manager.detect_conflicts(job_data, db_session)
if conflicts:
critical_conflicts = [c for c in conflicts if c.severity.value in ['kritisch', 'hoch']]
if critical_conflicts:
# Kritische Konflikte verhindern Job-Erstellung
conflict_descriptions = [c.description for c in critical_conflicts]
jobs_logger.warning(f"⚠️ Kritische Konflikte gefunden: {conflict_descriptions}")
db_session.close()
return jsonify({
"error": "Kritische Konflikte gefunden",
"conflicts": conflict_descriptions,
"suggestions": [s for c in critical_conflicts for s in c.suggested_solutions]
}), 409
# Mittlere/niedrige Konflikte protokollieren aber zulassen
jobs_logger.info(f"📋 {len(conflicts)} Konflikte erkannt, aber übergehbar")
# Prüfen, ob der Drucker online ist
printer_status, printer_active = check_printer_status(printer.plug_ip if printer.plug_ip else "")
jobs_logger.debug(f"🖨️ Drucker-Status: {printer_status}, aktiv: {printer_active}")
# Status basierend auf Drucker-Verfügbarkeit setzen
if printer_status == "online" and printer_active:
job_status = "scheduled"
else:
job_status = "waiting_for_printer"
jobs_logger.info(f"📋 Job-Status festgelegt: {job_status}")
# Neuen Job erstellen
new_job = Job(
name=name,
description=description,
printer_id=printer_id,
user_id=current_user.id,
owner_id=current_user.id,
start_at=start_at,
end_at=end_at,
status=job_status,
file_path=file_path,
duration_minutes=duration_minutes
)
db_session.add(new_job)
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = new_job.to_dict()
db_session.close()
jobs_logger.info(f"✅ Neuer Job {new_job.id} erfolgreich erstellt für Drucker {printer_id}, Start: {start_at}, Dauer: {duration_minutes} Minuten")
return jsonify({"job": job_dict}), 201
except Exception as db_error:
jobs_logger.error(f"❌ Datenbankfehler beim Job-Erstellen: {str(db_error)}")
try:
db_session.rollback()
db_session.close()
except:
pass
return jsonify({"error": "Datenbankfehler beim Erstellen des Jobs", "details": str(db_error)}), 500
except Exception as e:
jobs_logger.error(f"❌ Kritischer Fehler beim Erstellen eines Jobs: {str(e)}", exc_info=True)
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>', methods=['PUT'])
@login_required
@job_owner_required
def update_job(job_id):
"""Aktualisiert einen existierenden Job."""
try:
data = request.json
db_session = get_db_session()
job = db_session.query(Job).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Prüfen, ob der Job bearbeitet werden kann
if job.status in ["finished", "aborted"]:
db_session.close()
return jsonify({"error": f"Job kann im Status '{job.status}' nicht bearbeitet werden"}), 400
# Felder aktualisieren, falls vorhanden
if "name" in data:
job.name = data["name"]
if "description" in data:
job.description = data["description"]
if "notes" in data:
job.notes = data["notes"]
if "start_iso" in data:
try:
new_start = datetime.fromisoformat(data["start_iso"].replace('Z', '+00:00'))
job.start_at = new_start
# End-Zeit neu berechnen falls Duration verfügbar
if job.duration_minutes:
job.end_at = new_start + timedelta(minutes=job.duration_minutes)
except ValueError:
db_session.close()
return jsonify({"error": "Ungültiges Startdatum"}), 400
if "duration_minutes" in data:
duration = int(data["duration_minutes"])
if duration <= 0:
db_session.close()
return jsonify({"error": "Dauer muss größer als 0 sein"}), 400
job.duration_minutes = duration
# End-Zeit neu berechnen
if job.start_at:
job.end_at = job.start_at + timedelta(minutes=duration)
# Aktualisierungszeitpunkt setzen
job.updated_at = datetime.now()
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"Job {job_id} aktualisiert")
return jsonify({"job": job_dict})
except Exception as e:
jobs_logger.error(f"Fehler beim Aktualisieren von Job {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>', methods=['DELETE'])
@login_required
@job_owner_required
def delete_job(job_id):
"""Löscht einen Job."""
try:
db_session = get_db_session()
job = db_session.query(Job).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Prüfen, ob der Job gelöscht werden kann
if job.status == "running":
db_session.close()
return jsonify({"error": "Laufende Jobs können nicht gelöscht werden"}), 400
job_name = job.name
db_session.delete(job)
db_session.commit()
db_session.close()
jobs_logger.info(f"Job '{job_name}' (ID: {job_id}) gelöscht von Benutzer {current_user.id}")
return jsonify({"success": True, "message": "Job erfolgreich gelöscht"})
except Exception as e:
jobs_logger.error(f"Fehler beim Löschen des Jobs {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler"}), 500
@jobs_blueprint.route('/active', methods=['GET'])
@login_required
def get_active_jobs():
"""Gibt alle aktiven Jobs zurück."""
try:
db_session = get_db_session()
query = db_session.query(Job).options(
joinedload(Job.user),
joinedload(Job.printer)
).filter(
Job.status.in_(["scheduled", "running"])
)
# Normale Benutzer sehen nur ihre eigenen aktiven Jobs
if not current_user.is_admin:
query = query.filter(Job.user_id == current_user.id)
active_jobs = query.all()
result = []
for job in active_jobs:
job_dict = job.to_dict()
# Aktuelle Restzeit berechnen
if job.status == "running" and job.end_at:
remaining_time = job.end_at - datetime.now()
if remaining_time.total_seconds() > 0:
job_dict["remaining_minutes"] = int(remaining_time.total_seconds() / 60)
else:
job_dict["remaining_minutes"] = 0
result.append(job_dict)
db_session.close()
return jsonify({"jobs": result})
except Exception as e:
jobs_logger.error(f"Fehler beim Abrufen aktiver Jobs: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/current', methods=['GET'])
@login_required
def get_current_job():
"""Gibt den aktuell laufenden Job für den eingeloggten Benutzer zurück."""
db_session = get_db_session()
try:
current_job = db_session.query(Job).filter(
Job.user_id == current_user.id,
Job.status == "running"
).first()
if current_job:
job_dict = current_job.to_dict()
db_session.close()
return jsonify(job_dict)
else:
db_session.close()
return jsonify({"message": "Kein aktueller Job"}), 404
except Exception as e:
jobs_logger.error(f"Fehler beim Abrufen des aktuellen Jobs: {str(e)}")
db_session.close()
return jsonify({"error": "Interner Serverfehler"}), 500
@jobs_blueprint.route('/<int:job_id>/start', methods=['POST'])
@login_required
@job_owner_required
def start_job(job_id):
"""Startet einen Job manuell."""
try:
db_session = get_db_session()
job = db_session.query(Job).options(joinedload(Job.printer)).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Prüfen, ob der Job gestartet werden kann
if job.status not in ["scheduled", "waiting_for_printer"]:
db_session.close()
return jsonify({"error": f"Job kann im Status '{job.status}' nicht gestartet werden"}), 400
# Drucker-Status prüfen
if job.printer.plug_ip:
status, active = check_printer_status(job.printer.plug_ip)
if status != "online" or not active:
db_session.close()
return jsonify({"error": "Drucker ist nicht online"}), 400
# Job als laufend markieren
job.status = "running"
job.start_at = datetime.now()
job.end_at = job.start_at + timedelta(minutes=job.duration_minutes)
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"Job {job_id} manuell gestartet")
return jsonify({"job": job_dict})
except Exception as e:
jobs_logger.error(f"Fehler beim Starten von Job {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>/pause', methods=['POST'])
@login_required
@job_owner_required
def pause_job(job_id):
"""Pausiert einen laufenden Job."""
try:
db_session = get_db_session()
job = db_session.query(Job).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Prüfen, ob der Job pausiert werden kann
if job.status != "running":
db_session.close()
return jsonify({"error": f"Job kann im Status '{job.status}' nicht pausiert werden"}), 400
# Job pausieren
job.status = "paused"
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"Job {job_id} pausiert")
return jsonify({"job": job_dict})
except Exception as e:
jobs_logger.error(f"Fehler beim Pausieren von Job {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>/resume', methods=['POST'])
@login_required
@job_owner_required
def resume_job(job_id):
"""Setzt einen pausierten Job fort."""
try:
db_session = get_db_session()
job = db_session.query(Job).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Prüfen, ob der Job fortgesetzt werden kann
if job.status != "paused":
db_session.close()
return jsonify({"error": f"Job kann im Status '{job.status}' nicht fortgesetzt werden"}), 400
# Job fortsetzen
job.status = "running"
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"Job {job_id} fortgesetzt")
return jsonify({"job": job_dict})
except Exception as e:
jobs_logger.error(f"Fehler beim Fortsetzen von Job {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500
@jobs_blueprint.route('/<int:job_id>/finish', methods=['POST'])
@login_required
def finish_job(job_id):
"""Beendet einen Job manuell."""
try:
db_session = get_db_session()
job = db_session.query(Job).options(joinedload(Job.printer)).get(job_id)
if not job:
db_session.close()
return jsonify({"error": "Job nicht gefunden"}), 404
# Berechtigung prüfen
is_owner = job.user_id == int(current_user.id) or job.owner_id == int(current_user.id)
is_admin = current_user.is_admin
if not (is_owner or is_admin):
db_session.close()
return jsonify({"error": "Keine Berechtigung"}), 403
# Prüfen, ob der Job beendet werden kann
if job.status not in ["scheduled", "running", "paused"]:
db_session.close()
return jsonify({"error": f"Job kann im Status '{job.status}' nicht beendet werden"}), 400
# Job als beendet markieren
job.status = "finished"
job.actual_end_time = datetime.now()
db_session.commit()
# Job-Objekt für die Antwort serialisieren
job_dict = job.to_dict()
db_session.close()
jobs_logger.info(f"Job {job_id} manuell beendet durch Benutzer {current_user.id}")
return jsonify({"job": job_dict})
except Exception as e:
jobs_logger.error(f"Fehler beim manuellen Beenden von Job {job_id}: {str(e)}")
return jsonify({"error": "Interner Serverfehler", "details": str(e)}), 500