1719 lines
62 KiB
Python
Executable File
1719 lines
62 KiB
Python
Executable File
from flask import Flask, request, jsonify, g, redirect, url_for, session as flask_session, render_template, flash
|
|
from flask_cors import CORS
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
import secrets # Für bessere Salt-Generierung
|
|
from functools import wraps
|
|
import jwt
|
|
import datetime
|
|
import os
|
|
import json
|
|
import logging
|
|
import uuid
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from logging.handlers import RotatingFileHandler
|
|
from datetime import timedelta
|
|
from PyP100 import PyP100
|
|
from dotenv import load_dotenv
|
|
|
|
# Lade Umgebungsvariablen
|
|
load_dotenv()
|
|
|
|
# Initialisierung
|
|
app = Flask(__name__)
|
|
CORS(app, supports_credentials=True)
|
|
|
|
# Konfiguration
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_secret_key')
|
|
app.config['DATABASE'] = os.environ.get('DATABASE_PATH', 'instance/myp.db')
|
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
|
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('FLASK_ENV') == 'production'
|
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
|
app.config['JOB_CHECK_INTERVAL'] = int(os.environ.get('JOB_CHECK_INTERVAL', '60')) # Sekunden
|
|
|
|
# Steckdosen-Konfiguration
|
|
TAPO_USERNAME = os.environ.get('TAPO_USERNAME')
|
|
TAPO_PASSWORD = os.environ.get('TAPO_PASSWORD')
|
|
|
|
# Logging
|
|
if not os.path.exists('logs'):
|
|
os.mkdir('logs')
|
|
file_handler = RotatingFileHandler('logs/myp.log', maxBytes=10240, backupCount=10)
|
|
file_handler.setFormatter(logging.Formatter(
|
|
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
|
))
|
|
file_handler.setLevel(logging.INFO)
|
|
app.logger.addHandler(file_handler)
|
|
app.logger.setLevel(logging.INFO)
|
|
app.logger.info('MYP Backend starting up')
|
|
|
|
# Database functions
|
|
def get_db():
|
|
if 'db' not in g:
|
|
# Stelle sicher, dass das instance-Verzeichnis existiert
|
|
os.makedirs(os.path.dirname(app.config['DATABASE']), exist_ok=True)
|
|
g.db = sqlite3.connect(app.config['DATABASE'])
|
|
g.db.row_factory = sqlite3.Row
|
|
return g.db
|
|
|
|
def close_db(e=None):
|
|
db = g.pop('db', None)
|
|
if db is not None:
|
|
db.close()
|
|
|
|
def init_db():
|
|
"""Initialisiere die Datenbank, falls sie noch nicht existiert."""
|
|
db = get_db()
|
|
db.execute('PRAGMA foreign_keys = ON') # SQLite-Fremdschlüsselunterstützung aktivieren
|
|
|
|
# Tabellen erstellen
|
|
db.executescript('''
|
|
CREATE TABLE IF NOT EXISTS user (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT,
|
|
email TEXT UNIQUE,
|
|
role TEXT DEFAULT 'user'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS socket (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
status INTEGER DEFAULT 0,
|
|
ip_address TEXT,
|
|
last_seen TIMESTAMP,
|
|
connection_status TEXT DEFAULT 'unknown'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS job (
|
|
id TEXT PRIMARY KEY,
|
|
socket_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
duration_in_minutes INTEGER NOT NULL,
|
|
comments TEXT,
|
|
aborted INTEGER DEFAULT 0,
|
|
abort_reason TEXT,
|
|
waiting_approval INTEGER DEFAULT 0,
|
|
FOREIGN KEY (socket_id) REFERENCES socket (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS socket_uptime (
|
|
id TEXT PRIMARY KEY,
|
|
socket_id TEXT NOT NULL,
|
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
status TEXT NOT NULL,
|
|
duration_seconds INTEGER,
|
|
FOREIGN KEY (socket_id) REFERENCES socket (id) ON DELETE CASCADE
|
|
);
|
|
''')
|
|
|
|
# Überprüfe, ob die fehlenden Spalten bereits existieren, und füge sie hinzu, falls nicht
|
|
try:
|
|
# Prüfe, ob die connection_status-Spalte existiert
|
|
db.execute('SELECT connection_status FROM socket LIMIT 1')
|
|
except sqlite3.OperationalError:
|
|
# Spalte existiert nicht, füge sie hinzu
|
|
app.logger.info("Füge connection_status-Spalte zur socket-Tabelle hinzu")
|
|
db.execute('ALTER TABLE socket ADD COLUMN connection_status TEXT DEFAULT "unknown"')
|
|
|
|
try:
|
|
# Prüfe, ob die last_seen-Spalte existiert
|
|
db.execute('SELECT last_seen FROM socket LIMIT 1')
|
|
except sqlite3.OperationalError:
|
|
# Spalte existiert nicht, füge sie hinzu
|
|
app.logger.info("Füge last_seen-Spalte zur socket-Tabelle hinzu")
|
|
db.execute('ALTER TABLE socket ADD COLUMN last_seen TIMESTAMP')
|
|
|
|
db.commit()
|
|
|
|
PRINTERS = json.loads(os.environ.get('PRINTERS', '{}'))
|
|
|
|
def init_printers():
|
|
app.logger.info("Initialisiere Drucker aus Umgebungsvariablen")
|
|
db = get_db()
|
|
|
|
# Existierende IP-Adressen aus der Datenbank abrufen
|
|
existing_ips = {row['ip_address']: row['id'] for row in db.execute('SELECT id, ip_address FROM socket').fetchall() if row['ip_address']}
|
|
|
|
for printer_name, config in PRINTERS.items():
|
|
ip_address = config.get('ip')
|
|
if not ip_address:
|
|
continue # Überspringe Einträge ohne IP
|
|
description = f"Drucker mit IP: {ip_address}"
|
|
|
|
if ip_address in existing_ips:
|
|
app.logger.info(f"Drucker mit IP {ip_address} existiert bereits in der Datenbank")
|
|
# Setze den Status des existierenden Druckers auf 0 (verfügbar)
|
|
socket_id = existing_ips[ip_address]
|
|
update_socket(socket_id, status=0)
|
|
# Stelle sicher, dass die Steckdose wirklich ausgeschaltet ist
|
|
turn_off_socket(ip_address)
|
|
app.logger.info(f"Steckdose mit IP {ip_address} wurde beim Start ausgeschaltet")
|
|
else:
|
|
# Neuen Drucker eintragen
|
|
new_socket = create_socket(name=printer_name, description=description, ip_address=ip_address, status=0)
|
|
app.logger.info(f"Neuer Drucker angelegt: {printer_name} mit IP {ip_address}")
|
|
# Stelle sicher, dass die Steckdose wirklich ausgeschaltet ist
|
|
turn_off_socket(ip_address)
|
|
app.logger.info(f"Neue Steckdose mit IP {ip_address} wurde beim Start ausgeschaltet")
|
|
|
|
# Benutzerverwaltung
|
|
def get_user_by_id(user_id):
|
|
db = get_db()
|
|
row = db.execute('SELECT * FROM user WHERE id = ?', (user_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def get_user_by_username(username):
|
|
db = get_db()
|
|
row = db.execute('SELECT * FROM user WHERE username = ?', (username,)).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def get_all_users():
|
|
db = get_db()
|
|
rows = db.execute('SELECT * FROM user').fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def create_user(username, password, display_name=None, email=None, role='user'):
|
|
user_id = str(uuid.uuid4())
|
|
|
|
# Verwende einen sicheren Hash-Algorithmus (pbkdf2:sha256) mit mehr Iterationen (150000)
|
|
# und automatischem Salting durch Werkzeug
|
|
password_hash = generate_password_hash(
|
|
password,
|
|
method='pbkdf2:sha256',
|
|
salt_length=16 # Standardwert ist 8, aber wir erhöhen auf 16 für mehr Sicherheit
|
|
)
|
|
|
|
display_name = display_name or username
|
|
|
|
db = get_db()
|
|
db.execute(
|
|
'INSERT INTO user (id, username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?, ?)',
|
|
(user_id, username, password_hash, display_name, email, role)
|
|
)
|
|
db.commit()
|
|
|
|
app.logger.info(f"Benutzer {username} erstellt mit sicherem Password-Hash (pbkdf2:sha256, salt_length=16)")
|
|
return get_user_by_id(user_id)
|
|
|
|
def update_user(user_id, username=None, password=None, display_name=None, email=None, role=None):
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return None
|
|
|
|
values = []
|
|
params = []
|
|
|
|
if username:
|
|
values.append('username = ?')
|
|
params.append(username)
|
|
|
|
if password:
|
|
values.append('password_hash = ?')
|
|
# Verwende den gleichen verbesserten Hashing-Mechanismus wie bei create_user
|
|
params.append(generate_password_hash(
|
|
password,
|
|
method='pbkdf2:sha256',
|
|
salt_length=16
|
|
))
|
|
|
|
if display_name:
|
|
values.append('display_name = ?')
|
|
params.append(display_name)
|
|
|
|
if email:
|
|
values.append('email = ?')
|
|
params.append(email)
|
|
|
|
if role:
|
|
values.append('role = ?')
|
|
params.append(role)
|
|
|
|
if not values:
|
|
return user
|
|
|
|
query = f'UPDATE user SET {", ".join(values)} WHERE id = ?'
|
|
params.append(user_id)
|
|
|
|
db = get_db()
|
|
db.execute(query, params)
|
|
db.commit()
|
|
|
|
return get_user_by_id(user_id)
|
|
|
|
def delete_user(user_id):
|
|
db = get_db()
|
|
db.execute('DELETE FROM user WHERE id = ?', (user_id,))
|
|
db.commit()
|
|
return True
|
|
|
|
def check_password(user_dict, password):
|
|
# Überprüfe das Passwort mit dem gespeicherten Hash
|
|
is_valid = check_password_hash(user_dict['password_hash'], password)
|
|
|
|
# Wenn das Passwort gültig ist, überprüfe, ob der Hash aktualisiert werden muss
|
|
if is_valid:
|
|
# Überprüfe, ob der aktuelle Hash nicht das empfohlene Format verwendet
|
|
if not user_dict['password_hash'].startswith('pbkdf2:sha256:'):
|
|
# Hash muss aktualisiert werden, da er nicht den neuesten Sicherheitsstandards entspricht
|
|
app.logger.info(f"Migriere unsicheren Passwort-Hash für Benutzer {user_dict['username']} zu pbkdf2:sha256")
|
|
|
|
# Erstelle neuen Hash mit dem bestätigten Passwort
|
|
new_hash = generate_password_hash(
|
|
password,
|
|
method='pbkdf2:sha256',
|
|
salt_length=16
|
|
)
|
|
|
|
# Aktualisiere in der Datenbank
|
|
db = get_db()
|
|
db.execute('UPDATE user SET password_hash = ? WHERE id = ?',
|
|
(new_hash, user_dict['id']))
|
|
db.commit()
|
|
|
|
return is_valid
|
|
|
|
def user_to_dict(user):
|
|
if not user:
|
|
return None
|
|
return {
|
|
'id': user['id'],
|
|
'username': user['username'],
|
|
'displayName': user['display_name'],
|
|
'email': user['email'],
|
|
'role': user['role']
|
|
}
|
|
|
|
# Session-Verwaltung
|
|
def get_session_by_id(session_id):
|
|
db = get_db()
|
|
row = db.execute('SELECT * FROM session WHERE id = ?', (session_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def delete_sessions_by_user(user_id):
|
|
db = get_db()
|
|
db.execute('DELETE FROM session WHERE user_id = ?', (user_id,))
|
|
db.commit()
|
|
|
|
def create_session(user_id):
|
|
session_id = str(uuid.uuid4())
|
|
expires_at = datetime.datetime.utcnow() + timedelta(days=7)
|
|
|
|
db = get_db()
|
|
db.execute(
|
|
'INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)',
|
|
(session_id, user_id, expires_at.isoformat())
|
|
)
|
|
db.commit()
|
|
|
|
flask_session['session_id'] = session_id
|
|
flask_session.permanent = True
|
|
|
|
return session_id
|
|
|
|
def delete_session(session_id):
|
|
db = get_db()
|
|
db.execute('DELETE FROM session WHERE id = ?', (session_id,))
|
|
db.commit()
|
|
|
|
# Steckdosen-Verwaltung
|
|
def get_socket_by_id(socket_id):
|
|
db = get_db()
|
|
row = db.execute('SELECT * FROM socket WHERE id = ?', (socket_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def get_all_sockets():
|
|
db = get_db()
|
|
rows = db.execute('SELECT * FROM socket').fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def create_socket(name, description, ip_address=None, status=0):
|
|
socket_id = str(uuid.uuid4())
|
|
|
|
db = get_db()
|
|
db.execute(
|
|
'INSERT INTO socket (id, name, description, status, ip_address) VALUES (?, ?, ?, ?, ?)',
|
|
(socket_id, name, description, status, ip_address)
|
|
)
|
|
db.commit()
|
|
|
|
return get_socket_by_id(socket_id)
|
|
|
|
def update_socket(socket_id, name=None, description=None, status=None, ip_address=None):
|
|
socket = get_socket_by_id(socket_id)
|
|
if not socket:
|
|
return None
|
|
|
|
values = []
|
|
params = []
|
|
|
|
if name:
|
|
values.append('name = ?')
|
|
params.append(name)
|
|
|
|
if description:
|
|
values.append('description = ?')
|
|
params.append(description)
|
|
|
|
if status is not None:
|
|
values.append('status = ?')
|
|
params.append(status)
|
|
|
|
if ip_address:
|
|
values.append('ip_address = ?')
|
|
params.append(ip_address)
|
|
|
|
if not values:
|
|
return socket
|
|
|
|
query = f'UPDATE socket SET {", ".join(values)} WHERE id = ?'
|
|
params.append(socket_id)
|
|
|
|
db = get_db()
|
|
db.execute(query, params)
|
|
db.commit()
|
|
|
|
return get_socket_by_id(socket_id)
|
|
|
|
def delete_socket(socket_id):
|
|
db = get_db()
|
|
db.execute('DELETE FROM socket WHERE id = ?', (socket_id,))
|
|
db.commit()
|
|
return True
|
|
|
|
def get_latest_job_for_socket(socket_id):
|
|
db = get_db()
|
|
row = db.execute('''
|
|
SELECT * FROM job
|
|
WHERE socket_id = ?
|
|
ORDER BY start_at DESC
|
|
LIMIT 1
|
|
''', (socket_id,)).fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def socket_to_dict(socket):
|
|
if not socket:
|
|
return None
|
|
|
|
latest_job = get_latest_job_for_socket(socket['id'])
|
|
waiting_jobs = get_waiting_jobs_for_socket(socket['id'])
|
|
|
|
# Verbindungsstatus-Informationen
|
|
connection_status = socket.get('connection_status', 'unknown')
|
|
last_seen = socket.get('last_seen')
|
|
uptime_info = None
|
|
|
|
if last_seen and connection_status == 'offline':
|
|
# Berechne wie lange die Steckdose offline ist
|
|
try:
|
|
last_seen_dt = datetime.datetime.fromisoformat(last_seen)
|
|
now = datetime.datetime.utcnow()
|
|
offline_duration = int((now - last_seen_dt).total_seconds())
|
|
|
|
# Formatiere die Offline-Zeit benutzerfreundlich
|
|
hours, remainder = divmod(offline_duration, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
|
|
uptime_info = {
|
|
'offline_since': last_seen,
|
|
'offline_duration': offline_duration,
|
|
'offline_duration_formatted': f"{hours}h {minutes}m {seconds}s"
|
|
}
|
|
except (ValueError, TypeError):
|
|
# Wenn das Datum nicht geparst werden kann
|
|
uptime_info = {
|
|
'offline_since': last_seen,
|
|
'offline_duration': None,
|
|
'offline_duration_formatted': "Unbekannt"
|
|
}
|
|
|
|
return {
|
|
'id': socket['id'],
|
|
'name': socket['name'],
|
|
'description': socket['description'],
|
|
'status': socket['status'],
|
|
'ipAddress': socket.get('ip_address'),
|
|
'connectionStatus': connection_status,
|
|
'lastSeen': last_seen,
|
|
'uptimeInfo': uptime_info,
|
|
'latestJob': job_to_dict(latest_job) if latest_job else None,
|
|
'waitingJobs': [job_to_dict(job) for job in waiting_jobs] if waiting_jobs else []
|
|
}
|
|
|
|
# Job-Verwaltung
|
|
def get_job_by_id(job_id):
|
|
db = get_db()
|
|
row = db.execute('SELECT * FROM job WHERE id = ?', (job_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row)
|
|
|
|
def get_jobs_by_user(user_id):
|
|
db = get_db()
|
|
rows = db.execute('SELECT * FROM job WHERE user_id = ?', (user_id,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_all_jobs():
|
|
db = get_db()
|
|
rows = db.execute('SELECT * FROM job').fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_expired_jobs():
|
|
db = get_db()
|
|
now = datetime.datetime.utcnow().isoformat()
|
|
rows = db.execute('''
|
|
SELECT * FROM job
|
|
WHERE aborted = 0
|
|
AND waiting_approval = 0
|
|
AND datetime(start_at, '+' || duration_in_minutes || ' minutes') <= datetime(?)
|
|
''', (now,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_waiting_jobs_for_socket(socket_id):
|
|
"""Findet alle Jobs, die auf Freischaltung für eine bestimmte Steckdose warten."""
|
|
db = get_db()
|
|
rows = db.execute('''
|
|
SELECT * FROM job
|
|
WHERE socket_id = ?
|
|
AND aborted = 0
|
|
AND waiting_approval = 1
|
|
ORDER BY start_at ASC
|
|
''', (socket_id,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def create_job(socket_id, user_id, duration_in_minutes, comments=None, waiting_approval=0):
|
|
job_id = str(uuid.uuid4())
|
|
start_at = datetime.datetime.utcnow()
|
|
|
|
db = get_db()
|
|
db.execute(
|
|
'''INSERT INTO job
|
|
(id, socket_id, user_id, start_at, duration_in_minutes, comments, aborted, abort_reason, waiting_approval)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
|
(job_id, socket_id, user_id, start_at.isoformat(), duration_in_minutes, comments, 0, None, waiting_approval)
|
|
)
|
|
db.commit()
|
|
|
|
return get_job_by_id(job_id)
|
|
|
|
def update_job(job_id, socket_id=None, user_id=None, duration_in_minutes=None,
|
|
comments=None, aborted=None, abort_reason=None, waiting_approval=None):
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return None
|
|
|
|
values = []
|
|
params = []
|
|
|
|
if socket_id:
|
|
values.append('socket_id = ?')
|
|
params.append(socket_id)
|
|
|
|
if user_id:
|
|
values.append('user_id = ?')
|
|
params.append(user_id)
|
|
|
|
if duration_in_minutes:
|
|
values.append('duration_in_minutes = ?')
|
|
params.append(duration_in_minutes)
|
|
|
|
if comments is not None:
|
|
values.append('comments = ?')
|
|
params.append(comments)
|
|
|
|
if aborted is not None:
|
|
values.append('aborted = ?')
|
|
params.append(1 if aborted else 0)
|
|
|
|
if abort_reason is not None:
|
|
values.append('abort_reason = ?')
|
|
params.append(abort_reason)
|
|
|
|
if waiting_approval is not None:
|
|
values.append('waiting_approval = ?')
|
|
params.append(1 if waiting_approval else 0)
|
|
|
|
if not values:
|
|
return job
|
|
|
|
query = f'UPDATE job SET {", ".join(values)} WHERE id = ?'
|
|
params.append(job_id)
|
|
|
|
db = get_db()
|
|
db.execute(query, params)
|
|
db.commit()
|
|
|
|
return get_job_by_id(job_id)
|
|
|
|
def delete_job(job_id):
|
|
db = get_db()
|
|
db.execute('DELETE FROM job WHERE id = ?', (job_id,))
|
|
db.commit()
|
|
return True
|
|
|
|
def calculate_remaining_time(job):
|
|
if job['aborted']:
|
|
return 0
|
|
|
|
start_at = datetime.datetime.fromisoformat(job['start_at'])
|
|
end_at = start_at + timedelta(minutes=job['duration_in_minutes'])
|
|
|
|
now = datetime.datetime.utcnow()
|
|
if now > end_at:
|
|
return 0
|
|
|
|
diff = end_at - now
|
|
return int(diff.total_seconds() / 60)
|
|
|
|
def job_to_dict(job):
|
|
if not job:
|
|
return None
|
|
|
|
# Bei älteren Jobs könnte waiting_approval fehlen, deshalb mit get abrufen und Default setzen
|
|
waiting_approval = job.get('waiting_approval', 0) if isinstance(job, dict) else getattr(job, 'waiting_approval', 0)
|
|
|
|
return {
|
|
'id': job['id'],
|
|
'socketId': job['socket_id'],
|
|
'userId': job['user_id'],
|
|
'startAt': job['start_at'],
|
|
'durationInMinutes': job['duration_in_minutes'],
|
|
'comments': job['comments'],
|
|
'aborted': bool(job['aborted']),
|
|
'abortReason': job['abort_reason'],
|
|
'waitingApproval': bool(waiting_approval),
|
|
'remainingMinutes': calculate_remaining_time(job)
|
|
}
|
|
|
|
# Socket Uptime-Überwachung
|
|
def log_socket_connection_event(socket_id, status, duration_seconds=None):
|
|
"""Speichert ein Ereignis zum Verbindungsstatus einer Steckdose"""
|
|
event_id = str(uuid.uuid4())
|
|
timestamp = datetime.datetime.utcnow().isoformat()
|
|
|
|
db = get_db()
|
|
db.execute(
|
|
'INSERT INTO socket_uptime (id, socket_id, timestamp, status, duration_seconds) VALUES (?, ?, ?, ?, ?)',
|
|
(event_id, socket_id, timestamp, status, duration_seconds)
|
|
)
|
|
db.commit()
|
|
app.logger.info(f"Verbindungsstatus für Steckdose {socket_id} geändert: {status}")
|
|
|
|
# Aktualisiere auch den Verbindungsstatus in der socket-Tabelle
|
|
db.execute(
|
|
'UPDATE socket SET connection_status = ?, last_seen = ? WHERE id = ?',
|
|
(status, timestamp if status == 'online' else None, socket_id)
|
|
)
|
|
db.commit()
|
|
|
|
return event_id
|
|
|
|
def get_socket_uptime_events(socket_id=None, limit=100):
|
|
"""Ruft Verbindungsereignisse für eine oder alle Steckdosen ab"""
|
|
db = get_db()
|
|
|
|
if socket_id:
|
|
rows = db.execute('''
|
|
SELECT su.*, s.name, s.ip_address FROM socket_uptime su
|
|
JOIN socket s ON su.socket_id = s.id
|
|
WHERE su.socket_id = ?
|
|
ORDER BY su.timestamp DESC
|
|
LIMIT ?
|
|
''', (socket_id, limit)).fetchall()
|
|
else:
|
|
rows = db.execute('''
|
|
SELECT su.*, s.name, s.ip_address FROM socket_uptime su
|
|
JOIN socket s ON su.socket_id = s.id
|
|
ORDER BY su.timestamp DESC
|
|
LIMIT ?
|
|
''', (limit,)).fetchall()
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
def check_socket_connection(socket_id, timeout=8):
|
|
"""
|
|
Überprüft die Verbindung zu einer Steckdose und aktualisiert den Status.
|
|
|
|
Args:
|
|
socket_id: ID der Steckdose
|
|
timeout: Timeout in Sekunden, nach dem die Verbindung als fehlgeschlagen gilt
|
|
|
|
Returns:
|
|
True wenn die Steckdose online ist, sonst False
|
|
"""
|
|
socket = get_socket_by_id(socket_id)
|
|
if not socket or not socket['ip_address']:
|
|
return False
|
|
|
|
previous_status = socket.get('connection_status', 'unknown')
|
|
last_seen = socket.get('last_seen')
|
|
|
|
try:
|
|
# Verwende den Timeout-Parameter für die Geräteverbindung
|
|
device = get_socket_device(socket['ip_address'], timeout=timeout)
|
|
if device:
|
|
# Verbindung erfolgreich
|
|
if previous_status != 'online':
|
|
# Status hat sich von offline/unknown auf online geändert
|
|
duration = None
|
|
if previous_status == 'offline' and last_seen:
|
|
# Berechne die Dauer des Ausfalls
|
|
try:
|
|
offline_since = datetime.datetime.fromisoformat(last_seen)
|
|
now = datetime.datetime.utcnow()
|
|
duration = int((now - offline_since).total_seconds())
|
|
except (ValueError, TypeError):
|
|
# Wenn das Datum nicht geparst werden kann
|
|
duration = None
|
|
|
|
log_socket_connection_event(socket_id, 'online', duration)
|
|
return True
|
|
else:
|
|
# Keine Verbindung möglich oder Timeout
|
|
if previous_status != 'offline':
|
|
# Status hat sich von online/unknown auf offline geändert
|
|
log_socket_connection_event(socket_id, 'offline')
|
|
return False
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler bei der Überprüfung der Steckdose {socket['ip_address']}: {e}")
|
|
if previous_status != 'offline':
|
|
log_socket_connection_event(socket_id, 'offline')
|
|
return False
|
|
|
|
# Steckdosen-Steuerung mit PyP100
|
|
def get_socket_device(ip_address, timeout=8):
|
|
"""
|
|
Stellt eine Verbindung zu einer Tapo P100-Steckdose her, mit einem konfigurierbaren Timeout.
|
|
|
|
Args:
|
|
ip_address: IP-Adresse der Steckdose
|
|
timeout: Timeout in Sekunden, nach dem die Verbindung als fehlgeschlagen gilt
|
|
|
|
Returns:
|
|
Das PyP100-Geräteobjekt bei erfolgreicher Verbindung, sonst None
|
|
"""
|
|
try:
|
|
# Nutze Threading mit Timeout für die Verbindung
|
|
import threading
|
|
import queue
|
|
|
|
result_queue = queue.Queue()
|
|
|
|
def connect_with_timeout():
|
|
try:
|
|
device = PyP100.P100(ip_address, TAPO_USERNAME, TAPO_PASSWORD)
|
|
device.handshake() # Erstellt die erforderlichen Cookies
|
|
device.login() # Sendet Anmeldedaten und erstellt AES-Schlüssel
|
|
result_queue.put(device)
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler bei der Anmeldung an P100-Gerät {ip_address}: {e}")
|
|
result_queue.put(None)
|
|
|
|
# Starte den Verbindungsversuch in einem Thread
|
|
connect_thread = threading.Thread(target=connect_with_timeout)
|
|
connect_thread.daemon = True
|
|
connect_thread.start()
|
|
|
|
# Warte mit Timeout auf das Ergebnis
|
|
try:
|
|
device = result_queue.get(timeout=timeout)
|
|
if device:
|
|
app.logger.info(f"PyP100 Verbindung zu {ip_address} hergestellt")
|
|
return device
|
|
except queue.Empty:
|
|
app.logger.error(f"Timeout bei der Verbindung zu {ip_address} nach {timeout} Sekunden")
|
|
return None
|
|
|
|
except Exception as e:
|
|
app.logger.error(f"Unerwarteter Fehler bei der Anmeldung an P100-Gerät {ip_address}: {e}")
|
|
return None
|
|
|
|
def turn_on_socket(ip_address, timeout=8):
|
|
"""
|
|
Schaltet eine Steckdose ein mit konfiguriertem Timeout.
|
|
|
|
Args:
|
|
ip_address: IP-Adresse der Steckdose
|
|
timeout: Timeout in Sekunden für die Verbindung
|
|
|
|
Returns:
|
|
True bei Erfolg, False bei Fehlern oder Timeout
|
|
"""
|
|
try:
|
|
device = get_socket_device(ip_address, timeout=timeout)
|
|
if device:
|
|
device.turnOn()
|
|
app.logger.info(f"P100-Steckdose {ip_address} eingeschaltet")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Einschalten der P100-Steckdose {ip_address}: {e}")
|
|
return False
|
|
|
|
def turn_off_socket(ip_address, timeout=8):
|
|
"""
|
|
Schaltet eine Steckdose aus mit konfiguriertem Timeout.
|
|
|
|
Args:
|
|
ip_address: IP-Adresse der Steckdose
|
|
timeout: Timeout in Sekunden für die Verbindung
|
|
|
|
Returns:
|
|
True bei Erfolg, False bei Fehlern oder Timeout
|
|
"""
|
|
try:
|
|
device = get_socket_device(ip_address, timeout=timeout)
|
|
if device:
|
|
device.turnOff()
|
|
app.logger.info(f"P100-Steckdose {ip_address} ausgeschaltet")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der P100-Steckdose {ip_address}: {e}")
|
|
return False
|
|
|
|
# Authentifizierung und Autorisierung
|
|
def get_current_user():
|
|
session_id = flask_session.get('session_id')
|
|
if not session_id:
|
|
return None
|
|
|
|
session = get_session_by_id(session_id)
|
|
if not session or datetime.datetime.fromisoformat(session['expires_at']) < datetime.datetime.utcnow():
|
|
if session:
|
|
delete_session(session['id'])
|
|
flask_session.pop('session_id', None)
|
|
return None
|
|
|
|
return get_user_by_id(session['user_id'])
|
|
|
|
def login_required(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
user = get_current_user()
|
|
if not user:
|
|
return jsonify({'message': 'Authentifizierung erforderlich!'}), 401
|
|
|
|
g.current_user = user
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
def admin_required(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
if not g.get('current_user') or g.current_user['role'] != 'admin':
|
|
return jsonify({'message': 'Admin-Rechte erforderlich!'}), 403
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
# Authentifizierungs-Routen
|
|
@app.route('/auth/register', methods=['POST'])
|
|
def register():
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('username') or not data.get('password'):
|
|
return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400
|
|
|
|
username = data.get('username')
|
|
password = data.get('password')
|
|
display_name = data.get('displayName', username)
|
|
email = data.get('email', '')
|
|
|
|
if get_user_by_username(username):
|
|
return jsonify({'message': 'Benutzername bereits vergeben!'}), 400
|
|
|
|
# Prüfen, ob es bereits einen Admin gibt
|
|
db = get_db()
|
|
admin_exists = db.execute("SELECT 1 FROM user WHERE role = 'admin' LIMIT 1").fetchone() is not None
|
|
|
|
# Falls kein Admin existiert, wird der erste Benutzer zum Admin
|
|
role = 'admin' if not admin_exists else 'user'
|
|
|
|
user = create_user(username, password, display_name, email, role)
|
|
app.logger.info(f'Neuer Benutzer registriert: {username} (Rolle: {role})')
|
|
|
|
# Session erstellen
|
|
create_session(user['id'])
|
|
|
|
return jsonify({
|
|
'message': 'Registrierung erfolgreich!',
|
|
'user': user_to_dict(user)
|
|
}), 201
|
|
|
|
@app.route('/auth/login', methods=['POST'])
|
|
def login():
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('username') or not data.get('password'):
|
|
return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400
|
|
|
|
username = data.get('username')
|
|
password = data.get('password')
|
|
|
|
user = get_user_by_username(username)
|
|
|
|
if not user or not check_password(user, password):
|
|
return jsonify({'message': 'Ungültiger Benutzername oder Passwort!'}), 401
|
|
|
|
# Session erstellen
|
|
create_session(user['id'])
|
|
|
|
return jsonify({
|
|
'message': 'Anmeldung erfolgreich!',
|
|
'user': user_to_dict(user)
|
|
})
|
|
|
|
@app.route('/auth/logout', methods=['POST'])
|
|
def logout():
|
|
session_id = flask_session.get('session_id')
|
|
if session_id:
|
|
delete_session(session_id)
|
|
flask_session.pop('session_id', None)
|
|
|
|
return jsonify({'message': 'Erfolgreich abgemeldet!'}), 200
|
|
|
|
# API-Routen
|
|
@app.route('/api/me', methods=['GET'])
|
|
def get_me():
|
|
user = get_current_user()
|
|
if not user:
|
|
return jsonify({'authenticated': False}), 401
|
|
|
|
return jsonify({
|
|
'authenticated': True,
|
|
'user': user_to_dict(user)
|
|
})
|
|
|
|
@app.route('/api/printers', methods=['GET'])
|
|
def get_printers():
|
|
sockets = get_all_sockets()
|
|
return jsonify([socket_to_dict(socket) for socket in sockets])
|
|
|
|
@app.route('/api/printers', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def create_printer():
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('name') or not data.get('description'):
|
|
return jsonify({'message': 'Name und Beschreibung sind erforderlich!'}), 400
|
|
|
|
socket = create_socket(
|
|
name=data.get('name'),
|
|
description=data.get('description'),
|
|
status=data.get('status', 0),
|
|
ip_address=data.get('ipAddress')
|
|
)
|
|
|
|
return jsonify(socket_to_dict(socket)), 201
|
|
|
|
@app.route('/api/printers/<printer_id>', methods=['GET'])
|
|
def get_printer(printer_id):
|
|
socket = get_socket_by_id(printer_id)
|
|
if not socket:
|
|
return jsonify({'message': 'Steckdose nicht gefunden!'}), 404
|
|
return jsonify(socket_to_dict(socket))
|
|
|
|
@app.route('/api/printers/<printer_id>', methods=['PUT'])
|
|
@login_required
|
|
@admin_required
|
|
def update_printer(printer_id):
|
|
socket = get_socket_by_id(printer_id)
|
|
if not socket:
|
|
return jsonify({'message': 'Steckdose nicht gefunden!'}), 404
|
|
|
|
data = request.get_json()
|
|
|
|
updated_socket = update_socket(
|
|
printer_id,
|
|
name=data.get('name'),
|
|
description=data.get('description'),
|
|
status=data.get('status') if 'status' in data else None,
|
|
ip_address=data.get('ipAddress')
|
|
)
|
|
|
|
return jsonify(socket_to_dict(updated_socket))
|
|
|
|
@app.route('/api/printers/<printer_id>', methods=['DELETE'])
|
|
@login_required
|
|
@admin_required
|
|
def delete_printer(printer_id):
|
|
socket = get_socket_by_id(printer_id)
|
|
if not socket:
|
|
return jsonify({'message': 'Steckdose nicht gefunden!'}), 404
|
|
|
|
delete_socket(printer_id)
|
|
return jsonify({'message': 'Steckdose gelöscht!'})
|
|
|
|
@app.route('/api/jobs', methods=['GET'])
|
|
@login_required
|
|
def get_jobs():
|
|
# Admins sehen alle Jobs, normale Benutzer nur ihre eigenen
|
|
if g.current_user['role'] == 'admin':
|
|
jobs = get_all_jobs()
|
|
else:
|
|
jobs = get_jobs_by_user(g.current_user['id'])
|
|
|
|
return jsonify([job_to_dict(job) for job in jobs])
|
|
|
|
@app.route('/api/jobs', methods=['POST'])
|
|
@login_required
|
|
def create_job_endpoint():
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('printerId') or not data.get('durationInMinutes'):
|
|
return jsonify({'message': 'Steckdosen-ID und Dauer sind erforderlich!'}), 400
|
|
|
|
socket = get_socket_by_id(data['printerId'])
|
|
if not socket:
|
|
return jsonify({'message': 'Steckdose nicht gefunden!'}), 404
|
|
|
|
duration = int(data['durationInMinutes'])
|
|
allow_queued_jobs = data.get('allowQueuedJobs', False)
|
|
|
|
# Prüfen, ob der Drucker bereits belegt ist
|
|
if socket['status'] != 0: # 0 = available
|
|
if allow_queued_jobs:
|
|
# Erstelle einen Job, der auf Freischaltung wartet
|
|
job = create_job(
|
|
socket_id=socket['id'],
|
|
user_id=g.current_user['id'],
|
|
duration_in_minutes=duration,
|
|
comments=data.get('comments', ''),
|
|
waiting_approval=1 # Job wartet auf Freischaltung
|
|
)
|
|
app.logger.info(f"Wartender Job {job['id']} für belegten Drucker {socket['id']} erstellt.")
|
|
return jsonify(job_to_dict(job)), 201
|
|
else:
|
|
return jsonify({'message': 'Steckdose ist nicht verfügbar!'}), 400
|
|
|
|
# Normaler Job für verfügbaren Drucker
|
|
job = create_job(
|
|
socket_id=socket['id'],
|
|
user_id=g.current_user['id'],
|
|
duration_in_minutes=duration,
|
|
comments=data.get('comments', ''),
|
|
waiting_approval=0 # Job ist sofort aktiv
|
|
)
|
|
|
|
# Steckdose als belegt markieren
|
|
update_socket(socket['id'], status=1) # 1 = busy
|
|
|
|
# Steckdose einschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
try:
|
|
success = turn_on_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für Job {job['id']} eingeschaltet.")
|
|
else:
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} für Job {job['id']} nicht einschalten.")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Einschalten der Steckdose {socket['ip_address']}: {e}")
|
|
|
|
return jsonify(job_to_dict(job)), 201
|
|
|
|
@app.route('/api/jobs/<job_id>', methods=['GET'])
|
|
@login_required
|
|
def get_job_endpoint(job_id):
|
|
# Admins können alle Jobs sehen, Benutzer nur ihre eigenen
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
return jsonify(job_to_dict(job))
|
|
|
|
@app.route('/api/jobs/<job_id>/abort', methods=['POST'])
|
|
@login_required
|
|
def abort_job(job_id):
|
|
# Admins können alle Jobs abbrechen, Benutzer nur ihre eigenen
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
data = request.get_json()
|
|
|
|
updated_job = update_job(job_id, aborted=True, abort_reason=data.get('reason', ''))
|
|
|
|
# Steckdose wieder verfügbar machen
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
if socket:
|
|
update_socket(socket['id'], status=0) # 0 = available
|
|
|
|
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
# Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist
|
|
max_attempts = 3
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
success = turn_off_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für abgebrochenen Job {job['id']} ausgeschaltet (Versuch {attempt}).")
|
|
break
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})")
|
|
|
|
# Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen
|
|
if attempt < max_attempts:
|
|
import time
|
|
time.sleep(1)
|
|
|
|
return jsonify(job_to_dict(updated_job))
|
|
|
|
@app.route('/api/jobs/<job_id>/finish', methods=['POST'])
|
|
@login_required
|
|
def finish_job(job_id):
|
|
# Admins können alle Jobs beenden, Benutzer nur ihre eigenen
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
# Aktuelle Zeit als Ende setzen
|
|
now = datetime.datetime.utcnow()
|
|
start_at = datetime.datetime.fromisoformat(job['start_at'])
|
|
actual_duration = int((now - start_at).total_seconds() / 60)
|
|
|
|
updated_job = update_job(job_id, duration_in_minutes=actual_duration)
|
|
|
|
# Steckdose wieder verfügbar machen
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
if socket:
|
|
update_socket(socket['id'], status=0) # 0 = available
|
|
|
|
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
# Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist
|
|
max_attempts = 3
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
success = turn_off_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für beendeten Job {job['id']} ausgeschaltet (Versuch {attempt}).")
|
|
break
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})")
|
|
|
|
# Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen
|
|
if attempt < max_attempts:
|
|
import time
|
|
time.sleep(1)
|
|
|
|
return jsonify(job_to_dict(updated_job))
|
|
|
|
@app.route('/api/jobs/<job_id>/extend', methods=['POST'])
|
|
@login_required
|
|
def extend_job(job_id):
|
|
# Admins können alle Jobs verlängern, Benutzer nur ihre eigenen
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
data = request.get_json()
|
|
minutes = int(data.get('minutes', 0))
|
|
hours = int(data.get('hours', 0))
|
|
|
|
additional_minutes = minutes + (hours * 60)
|
|
if additional_minutes <= 0:
|
|
return jsonify({'message': 'Ungültige Verlängerungszeit!'}), 400
|
|
|
|
new_duration = job['duration_in_minutes'] + additional_minutes
|
|
updated_job = update_job(job_id, duration_in_minutes=new_duration)
|
|
|
|
return jsonify(job_to_dict(updated_job))
|
|
|
|
@app.route('/api/jobs/<job_id>/approve', methods=['POST'])
|
|
@login_required
|
|
def approve_job(job_id):
|
|
"""Aktiviert einen wartenden Job und schaltet die Steckdose ein."""
|
|
# Nur Admins oder der Job-Ersteller können Jobs freischalten
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
# Prüfen, ob Job auf Freischaltung wartet
|
|
waiting_approval = job.get('waiting_approval', 0)
|
|
if not waiting_approval:
|
|
return jsonify({'message': 'Dieser Job wartet nicht auf Freischaltung!'}), 400
|
|
|
|
# Drucker abrufen
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
if not socket:
|
|
return jsonify({'message': 'Drucker nicht gefunden!'}), 404
|
|
|
|
# Prüfen, ob der Drucker verfügbar ist
|
|
if socket['status'] != 0: # 0 = available
|
|
return jsonify({'message': 'Drucker ist noch belegt! Bitte warten, bis der laufende Job beendet ist.'}), 400
|
|
|
|
# Job aktualisieren
|
|
updated_job = update_job(job_id, waiting_approval=0)
|
|
|
|
# Steckdose als belegt markieren
|
|
update_socket(socket['id'], status=1) # 1 = busy
|
|
|
|
# Steckdose einschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
try:
|
|
success = turn_on_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für freigeschalteten Job {job['id']} eingeschaltet.")
|
|
else:
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} für freigeschalteten Job {job['id']} nicht einschalten.")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Einschalten der Steckdose {socket['ip_address']}: {e}")
|
|
|
|
return jsonify(job_to_dict(updated_job))
|
|
|
|
@app.route('/api/jobs/<job_id>/comments', methods=['PUT'])
|
|
@login_required
|
|
def update_job_comments(job_id):
|
|
# Admins können alle Jobs bearbeiten, Benutzer nur ihre eigenen
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
if g.current_user['role'] != 'admin' and job['user_id'] != g.current_user['id']:
|
|
return jsonify({'message': 'Keine Berechtigung für diesen Job!'}), 403
|
|
|
|
data = request.get_json()
|
|
updated_job = update_job(job_id, comments=data.get('comments', ''))
|
|
|
|
return jsonify(job_to_dict(updated_job))
|
|
|
|
@app.route('/api/job/<job_id>/remaining-time', methods=['GET'])
|
|
def job_remaining_time(job_id):
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
remaining = calculate_remaining_time(job)
|
|
|
|
# Wenn die verbleibende Zeit 0 ist und der Job nicht manuell abgebrochen wurde,
|
|
# automatisch die Steckdose ausschalten und Status aktualisieren
|
|
if remaining == 0 and not job['aborted']:
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
if socket and socket['status'] == 1: # busy
|
|
update_socket(socket['id'], status=0) # available
|
|
app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.")
|
|
|
|
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
# Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist
|
|
max_attempts = 3
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
success = turn_off_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).")
|
|
break
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})")
|
|
|
|
# Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen
|
|
if attempt < max_attempts:
|
|
import time
|
|
time.sleep(1)
|
|
|
|
return jsonify({
|
|
'remaining_minutes': remaining,
|
|
'job_status': 'completed' if remaining == 0 else 'active',
|
|
'socket_status': 'available' if remaining == 0 else 'busy'
|
|
})
|
|
|
|
@app.route('/api/users', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def get_users():
|
|
users = get_all_users()
|
|
return jsonify([user_to_dict(user) for user in users])
|
|
|
|
@app.route('/api/users/<user_id>', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def get_user(user_id):
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return jsonify({'message': 'Benutzer nicht gefunden!'}), 404
|
|
return jsonify(user_to_dict(user))
|
|
|
|
@app.route('/api/users/<user_id>', methods=['PUT'])
|
|
@login_required
|
|
@admin_required
|
|
def update_user_endpoint(user_id):
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return jsonify({'message': 'Benutzer nicht gefunden!'}), 404
|
|
|
|
data = request.get_json()
|
|
updated_user = update_user(
|
|
user_id,
|
|
username=data.get('username'),
|
|
password=data.get('password'),
|
|
display_name=data.get('displayName'),
|
|
email=data.get('email'),
|
|
role=data.get('role')
|
|
)
|
|
|
|
return jsonify(user_to_dict(updated_user))
|
|
|
|
@app.route('/api/users/<user_id>', methods=['DELETE'])
|
|
@login_required
|
|
@admin_required
|
|
def delete_user_endpoint(user_id):
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return jsonify({'message': 'Benutzer nicht gefunden!'}), 404
|
|
|
|
# Löschen aller Sessions des Benutzers
|
|
delete_sessions_by_user(user_id)
|
|
|
|
delete_user(user_id)
|
|
return jsonify({'message': 'Benutzer gelöscht!'})
|
|
|
|
@app.route('/api/stats', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def stats():
|
|
db = get_db()
|
|
|
|
# Steckdosen-Nutzungsstatistiken
|
|
total_sockets = db.execute('SELECT COUNT(*) as count FROM socket').fetchone()['count']
|
|
available_sockets = db.execute('SELECT COUNT(*) as count FROM socket WHERE status = 0').fetchone()['count']
|
|
|
|
# Verbindungsstatistiken
|
|
online_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'online'").fetchone()['count']
|
|
offline_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'offline'").fetchone()['count']
|
|
unknown_sockets = db.execute("SELECT COUNT(*) as count FROM socket WHERE connection_status = 'unknown' OR connection_status IS NULL").fetchone()['count']
|
|
|
|
# Job-Statistiken
|
|
total_jobs = db.execute('SELECT COUNT(*) as count FROM job').fetchone()['count']
|
|
|
|
now = datetime.datetime.utcnow().isoformat()
|
|
active_jobs = db.execute('''
|
|
SELECT COUNT(*) as count FROM job
|
|
WHERE aborted = 0
|
|
AND datetime(start_at, '+' || duration_in_minutes || ' minutes') > datetime(?)
|
|
''', (now,)).fetchone()['count']
|
|
|
|
completed_jobs = db.execute('''
|
|
SELECT COUNT(*) as count FROM job
|
|
WHERE aborted = 0
|
|
AND datetime(start_at, '+' || duration_in_minutes || ' minutes') <= datetime(?)
|
|
''', (now,)).fetchone()['count']
|
|
|
|
# Benutzerstatistiken
|
|
total_users = db.execute('SELECT COUNT(*) as count FROM user').fetchone()['count']
|
|
|
|
# Durchschnittliche Druckdauer
|
|
avg_duration_result = db.execute('SELECT AVG(duration_in_minutes) as avg FROM job').fetchone()
|
|
avg_duration = int(avg_duration_result['avg']) if avg_duration_result['avg'] else 0
|
|
|
|
# Steckdosen-Fehlerstatistiken (letzten 7 Tage)
|
|
seven_days_ago = (datetime.datetime.utcnow() - timedelta(days=7)).isoformat()
|
|
outages = db.execute('''
|
|
SELECT COUNT(*) as count FROM socket_uptime
|
|
WHERE status = 'offline'
|
|
AND timestamp > ?
|
|
''', (seven_days_ago,)).fetchone()['count']
|
|
|
|
# Steckdosen mit aktuellen Problemen
|
|
problem_sockets = db.execute('''
|
|
SELECT s.name, s.connection_status, s.last_seen
|
|
FROM socket s
|
|
WHERE s.connection_status = 'offline'
|
|
''').fetchall()
|
|
|
|
return jsonify({
|
|
'printers': {
|
|
'total': total_sockets,
|
|
'available': available_sockets,
|
|
'utilization_rate': (total_sockets - available_sockets) / total_sockets if total_sockets > 0 else 0,
|
|
'online': online_sockets,
|
|
'offline': offline_sockets,
|
|
'unknown': unknown_sockets,
|
|
'connectivity_rate': online_sockets / total_sockets if total_sockets > 0 else 0
|
|
},
|
|
'jobs': {
|
|
'total': total_jobs,
|
|
'active': active_jobs,
|
|
'completed': completed_jobs,
|
|
'avg_duration': avg_duration
|
|
},
|
|
'users': {
|
|
'total': total_users
|
|
},
|
|
'uptime': {
|
|
'outages_last_7_days': outages,
|
|
'problem_printers': [{'name': row['name'], 'status': row['connection_status'], 'last_seen': row['last_seen']} for row in problem_sockets]
|
|
}
|
|
})
|
|
|
|
@app.route('/api/uptime', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def uptime_stats():
|
|
"""Liefert detaillierte Uptime-Statistiken für das Dashboard."""
|
|
socket_id = request.args.get('socket_id')
|
|
limit = int(request.args.get('limit', 100))
|
|
|
|
# Rufe die letzten Uptime-Ereignisse ab
|
|
events = get_socket_uptime_events(socket_id, limit)
|
|
|
|
# Gruppiere Ereignisse nach Steckdose
|
|
sockets = {}
|
|
for event in events:
|
|
socket_id = event['socket_id']
|
|
if socket_id not in sockets:
|
|
sockets[socket_id] = {
|
|
'id': socket_id,
|
|
'name': event['name'],
|
|
'ip_address': event['ip_address'],
|
|
'events': []
|
|
}
|
|
|
|
# Füge Ereignis zur Steckdosenliste hinzu
|
|
sockets[socket_id]['events'].append({
|
|
'id': event['id'],
|
|
'timestamp': event['timestamp'],
|
|
'status': event['status'],
|
|
'duration_seconds': event['duration_seconds']
|
|
})
|
|
|
|
# Hole den aktuellen Status aller Steckdosen
|
|
all_sockets = get_all_sockets()
|
|
current_status = {}
|
|
for socket in all_sockets:
|
|
current_status[socket['id']] = {
|
|
'connection_status': socket.get('connection_status', 'unknown'),
|
|
'last_seen': socket.get('last_seen')
|
|
}
|
|
|
|
# Füge den aktuellen Status zu den Socket-Informationen hinzu
|
|
for socket_id, socket_data in sockets.items():
|
|
if socket_id in current_status:
|
|
socket_data['current_status'] = current_status[socket_id]
|
|
|
|
return jsonify({
|
|
'sockets': list(sockets.values())
|
|
})
|
|
|
|
# Regelmäßige Überprüfung der Jobs und automatische Abschaltung der Steckdosen
|
|
def check_jobs():
|
|
"""Überprüft abgelaufene Jobs und schaltet Steckdosen automatisch aus."""
|
|
with app.app_context():
|
|
expired_jobs = get_expired_jobs()
|
|
handled_jobs = 0
|
|
|
|
for job in expired_jobs:
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
|
|
if socket and socket['status'] == 1: # busy
|
|
update_socket(socket['id'], status=0) # available
|
|
app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.")
|
|
handled_jobs += 1
|
|
|
|
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
# Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist
|
|
max_attempts = 3
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
success = turn_off_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).")
|
|
break
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})")
|
|
|
|
# Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen
|
|
if attempt < max_attempts:
|
|
time.sleep(1)
|
|
|
|
app.logger.info(f"{len(expired_jobs)} abgelaufene Jobs überprüft, {handled_jobs} Steckdosen aktualisiert.")
|
|
|
|
def check_socket_connections():
|
|
"""Überprüft periodisch die Verbindung zu allen Steckdosen mit 8-Sekunden-Timeout."""
|
|
with app.app_context():
|
|
sockets = get_all_sockets()
|
|
app.logger.info(f"Überprüfe Verbindungsstatus von {len(sockets)} Steckdosen")
|
|
|
|
online_count = 0
|
|
offline_count = 0
|
|
skipped_count = 0
|
|
|
|
for socket in sockets:
|
|
if not socket['ip_address']:
|
|
skipped_count += 1
|
|
continue # Überspringe Steckdosen ohne IP-Adresse
|
|
|
|
is_online = check_socket_connection(socket['id'])
|
|
if is_online:
|
|
online_count += 1
|
|
else:
|
|
offline_count += 1
|
|
app.logger.warning(f"Steckdose {socket['name']} ({socket['ip_address']}) ist nicht erreichbar")
|
|
|
|
app.logger.info(f"Verbindungsüberprüfung abgeschlossen: {online_count} online, {offline_count} offline, {skipped_count} übersprungen")
|
|
|
|
# Hintergrund-Thread für das Job-Polling und Steckdosen-Monitoring
|
|
def background_job_checker():
|
|
"""Hintergrund-Thread, der regelmäßig abgelaufene Jobs und Steckdosenverbindungen überprüft."""
|
|
app.logger.info("Starte Hintergrund-Thread für Job-Überprüfung und Steckdosen-Monitoring")
|
|
|
|
# Standardintervall für Socket-Überprüfungen (2 Minuten)
|
|
socket_check_interval = int(os.environ.get('SOCKET_CHECK_INTERVAL', '120'))
|
|
last_socket_check = 0
|
|
|
|
while True:
|
|
try:
|
|
# Überprüfe Jobs bei jedem Durchlauf
|
|
check_jobs()
|
|
|
|
# Überprüfe Steckdosen in regelmäßigen Intervallen
|
|
current_time = time.time()
|
|
if current_time - last_socket_check >= socket_check_interval:
|
|
# Socket-Überprüfung mit 8-Sekunden-Timeout pro Gerät
|
|
check_socket_connections()
|
|
last_socket_check = current_time
|
|
app.logger.info(f"Nächste Socket-Überprüfung in {socket_check_interval} Sekunden")
|
|
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler im Hintergrund-Thread: {e}")
|
|
|
|
# Pause zwischen den Überprüfungen
|
|
time.sleep(app.config['JOB_CHECK_INTERVAL'])
|
|
|
|
# CLI-Befehle für manuelle Ausführung
|
|
@app.cli.command("check-jobs")
|
|
def cli_check_jobs():
|
|
"""CLI-Befehl zur manuellen Überprüfung abgelaufener Jobs."""
|
|
check_jobs()
|
|
|
|
@app.cli.command("check-sockets")
|
|
def cli_check_sockets():
|
|
"""CLI-Befehl zur manuellen Überprüfung aller Steckdosenverbindungen."""
|
|
check_socket_connections()
|
|
|
|
@app.route('/api/job/<job_id>/status', methods=['GET'])
|
|
def job_status(job_id):
|
|
"""Endpunkt zum Überprüfen des Status eines Jobs für Frontend-Polling."""
|
|
job = get_job_by_id(job_id)
|
|
if not job:
|
|
return jsonify({'message': 'Job nicht gefunden!'}), 404
|
|
|
|
remaining = calculate_remaining_time(job)
|
|
socket = get_socket_by_id(job['socket_id'])
|
|
socket_status = socket['status'] if socket else None
|
|
|
|
# Wenn die verbleibende Zeit 0 ist und der Job nicht manuell abgebrochen wurde,
|
|
# automatisch die Steckdose ausschalten und Status aktualisieren
|
|
if remaining == 0 and not job['aborted'] and socket and socket['status'] == 1:
|
|
# Update socket status to available
|
|
update_socket(socket['id'], status=0)
|
|
socket_status = 0
|
|
app.logger.info(f"Job {job['id']} abgelaufen. Steckdose {socket['id']} auf verfügbar gesetzt.")
|
|
|
|
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
|
|
if socket['ip_address']:
|
|
# Mehrmals versuchen, die Steckdose auszuschalten, um sicherzustellen, dass sie wirklich aus ist
|
|
max_attempts = 3
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
success = turn_off_socket(socket['ip_address'])
|
|
if success:
|
|
app.logger.info(f"Steckdose {socket['ip_address']} für abgelaufenen Job {job['id']} automatisch ausgeschaltet (Versuch {attempt}).")
|
|
break
|
|
app.logger.warning(f"Konnte Steckdose {socket['ip_address']} nicht ausschalten (Versuch {attempt}/{max_attempts}).")
|
|
except Exception as e:
|
|
app.logger.error(f"Fehler beim Ausschalten der Steckdose {socket['ip_address']}: {e} (Versuch {attempt}/{max_attempts})")
|
|
|
|
# Nur wenn es nicht der letzte Versuch war, kurz warten und neu versuchen
|
|
if attempt < max_attempts:
|
|
import time
|
|
time.sleep(1)
|
|
|
|
job_status = 'aborted' if job['aborted'] else ('completed' if remaining == 0 else 'active')
|
|
|
|
return jsonify({
|
|
'job': job_to_dict(job),
|
|
'status': job_status,
|
|
'socketStatus': 'available' if socket_status == 0 else 'busy',
|
|
'remainingMinutes': remaining
|
|
})
|
|
|
|
@app.route('/api/test', methods=['GET'])
|
|
def test():
|
|
return jsonify({'message': 'MYP Backend API funktioniert!'})
|
|
|
|
@app.route('/api/create-initial-admin', methods=['POST'])
|
|
def create_initial_admin():
|
|
db = get_db()
|
|
admin_exists = db.execute("SELECT 1 FROM user WHERE role = 'admin' LIMIT 1").fetchone() is not None
|
|
|
|
if admin_exists:
|
|
return jsonify({'message': 'Es existiert bereits ein Administrator!'}), 400
|
|
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('username') or not data.get('password'):
|
|
return jsonify({'message': 'Benutzername und Passwort sind erforderlich!'}), 400
|
|
|
|
username = data.get('username')
|
|
password = data.get('password')
|
|
display_name = data.get('displayName', username)
|
|
email = data.get('email', '')
|
|
|
|
user = create_user(username, password, display_name, email, 'admin')
|
|
app.logger.info(f'Initialer Admin-Benutzer erstellt: {username}')
|
|
|
|
return jsonify({
|
|
'message': 'Administrator wurde erfolgreich erstellt!',
|
|
'user': user_to_dict(user)
|
|
}), 201
|
|
|
|
# Error Handler
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
return jsonify({'message': 'Nicht gefunden!'}), 404
|
|
|
|
@app.errorhandler(500)
|
|
def server_error(error):
|
|
app.logger.error(f'Serverfehler: {error}')
|
|
return jsonify({'message': 'Interner Serverfehler!'}), 500
|
|
|
|
# Web UI Routen
|
|
@app.route('/')
|
|
def index():
|
|
current_user = get_current_user()
|
|
if current_user:
|
|
return render_template('dashboard.html', current_user=current_user, active_page='home')
|
|
return redirect(url_for('login_page'))
|
|
|
|
@app.route('/login')
|
|
def login_page():
|
|
return render_template('login.html', active_page='login')
|
|
|
|
@app.route('/register')
|
|
def register_page():
|
|
return render_template('register.html', active_page='register')
|
|
|
|
@app.route('/logout')
|
|
def logout_page():
|
|
session_id = flask_session.get('session_id')
|
|
if session_id:
|
|
delete_session(session_id)
|
|
flask_session.pop('session_id', None)
|
|
|
|
flash('Sie wurden erfolgreich abgemeldet.', 'success')
|
|
return redirect(url_for('login_page'))
|
|
|
|
@app.route('/admin/printers')
|
|
def printers_page():
|
|
current_user = get_current_user()
|
|
if not current_user:
|
|
return redirect(url_for('login_page'))
|
|
return render_template('printers.html', current_user=current_user, active_page='printers')
|
|
|
|
@app.route('/admin/jobs')
|
|
def jobs_page():
|
|
current_user = get_current_user()
|
|
if not current_user:
|
|
return redirect(url_for('login_page'))
|
|
return render_template('jobs.html', current_user=current_user, active_page='jobs')
|
|
|
|
@app.route('/admin/users')
|
|
def users_page():
|
|
current_user = get_current_user()
|
|
if not current_user or current_user['role'] != 'admin':
|
|
flash('Sie haben keine Berechtigung, diese Seite zu besuchen.', 'danger')
|
|
return redirect(url_for('index'))
|
|
return render_template('users.html', current_user=current_user, active_page='users')
|
|
|
|
@app.route('/admin/stats')
|
|
def stats_page():
|
|
current_user = get_current_user()
|
|
if not current_user or current_user['role'] != 'admin':
|
|
flash('Sie haben keine Berechtigung, diese Seite zu besuchen.', 'danger')
|
|
return redirect(url_for('index'))
|
|
return render_template('stats.html', current_user=current_user, active_page='stats')
|
|
|
|
# Initialisierung und Start des Hintergrund-Threads beim ersten Request
|
|
with app.app_context():
|
|
# Diese Funktion wird nach dem App-Start aber vor dem ersten Request ausgeführt
|
|
@app.before_request
|
|
def initialize_background_tasks():
|
|
"""Startet den Hintergrund-Thread für Job-Überprüfung beim ersten Request."""
|
|
# Überprüfung, ob dieser Handler bereits ausgeführt wurde
|
|
if getattr(app, '_job_thread_initialized', False):
|
|
return
|
|
|
|
# Starte den Hintergrund-Thread nur, wenn er noch nicht läuft
|
|
for thread in threading.enumerate():
|
|
if thread.name == 'job_checker_thread':
|
|
app.logger.info("Hintergrund-Thread für Job-Überprüfung läuft bereits")
|
|
app._job_thread_initialized = True
|
|
return
|
|
|
|
# Thread starten
|
|
job_thread = threading.Thread(target=background_job_checker, daemon=True, name='job_checker_thread')
|
|
job_thread.start()
|
|
app.logger.info("Hintergrund-Thread für Job-Überprüfung beim ersten Request gestartet")
|
|
app._job_thread_initialized = True
|
|
|
|
# Server starten
|
|
if __name__ == '__main__':
|
|
with app.app_context():
|
|
init_db()
|
|
if PRINTERS:
|
|
# Initialisiere Drucker und schalte alle Steckdosen beim Start aus
|
|
init_printers()
|
|
|
|
# Starte den Hintergrund-Thread für die Job-Überprüfung
|
|
job_thread = threading.Thread(target=background_job_checker, daemon=True, name='job_checker_thread')
|
|
job_thread.start()
|
|
app.logger.info("Hintergrund-Thread für Job-Überprüfung gestartet")
|
|
|
|
app.run(debug=True, host='0.0.0.0') |