""" 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('/', 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('/', 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('/', 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('//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('//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('//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('//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