From 491551309c1f871eb3bf1521a5b860859212f10a Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Mon, 16 Jun 2025 21:52:19 +0200 Subject: [PATCH] Refactoring - Part 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Konfiguration extrahiert (config.py) - Alle App-Einstellungen zentralisiert - Flask-Konfiguration, Datenbank, Backup, Rate-Limiting - 576 Zeilen Code reduziert 2. Datenbank-Layer (db.py) - Connection Management mit Context Managers - Helper-Funktionen für Queries - Saubere Fehlerbehandlung 3. Auth-Module (auth/) - decorators.py - Login-Required mit Session-Timeout - password.py - Bcrypt Hashing - two_factor.py - TOTP, QR-Codes, Backup-Codes - rate_limiting.py - IP-Blocking, Login-Versuche 4. Utility-Module (utils/) - audit.py - Audit-Logging - backup.py - Verschlüsselte Backups - license.py - Lizenzschlüssel-Generierung - export.py - Excel-Export - network.py - IP-Ermittlung - recaptcha.py - reCAPTCHA-Verifikation 5. Models (models.py) - User-Model-Funktionen --- .claude/settings.local.json | 3 +- .../app_no_duplicates.cpython-312.pyc | Bin 0 -> 138120 bytes v2_adminpanel/app.py | 8 + v2_adminpanel/app_before_blueprint.py | 4460 ++++++++++++++++ v2_adminpanel/app_with_duplicates.py | 4462 +++++++++++++++++ v2_adminpanel/comment_routes.py | 42 + v2_adminpanel/remove_duplicate_routes.py | 52 + v2_adminpanel/routes/__init__.py | 2 + v2_adminpanel/routes/admin_routes.py | 540 ++ v2_adminpanel/routes/auth_routes.py | 377 ++ v2_adminpanel/test_blueprints.py | 21 + v2_adminpanel/utils/recaptcha.py | 39 + 12 files changed, 10005 insertions(+), 1 deletion(-) create mode 100644 v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc create mode 100644 v2_adminpanel/app_before_blueprint.py create mode 100644 v2_adminpanel/app_with_duplicates.py create mode 100644 v2_adminpanel/comment_routes.py create mode 100644 v2_adminpanel/remove_duplicate_routes.py create mode 100644 v2_adminpanel/routes/__init__.py create mode 100644 v2_adminpanel/routes/admin_routes.py create mode 100644 v2_adminpanel/routes/auth_routes.py create mode 100644 v2_adminpanel/test_blueprints.py create mode 100644 v2_adminpanel/utils/recaptcha.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 09cef33..0238830 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -62,7 +62,8 @@ "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/profile.html /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resource_metrics.html)", "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)", "Bash(sed:*)", - "Bash(python:*)" + "Bash(python:*)", + "Bash(awk:*)" ], "deny": [] } diff --git a/v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc b/v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d47d7f32a81f57909f7ffddff16f560e9b82d074 GIT binary patch literal 138120 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%0A5XkuIALx@^U0;3up8w<;Sq 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 G|Nj6Q=J9O+ literal 0 HcmV?d00001 diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index deba93a..0622714 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -74,6 +74,14 @@ scheduler.start() # Logging konfigurieren logging.basicConfig(level=logging.INFO) +# Import and register blueprints +from routes.auth_routes import auth_bp +from routes.admin_routes import admin_bp + +# Temporarily comment out blueprints to avoid conflicts +# app.register_blueprint(auth_bp) +# app.register_blueprint(admin_bp) + # Scheduled Backup Job def scheduled_backup(): diff --git a/v2_adminpanel/app_before_blueprint.py b/v2_adminpanel/app_before_blueprint.py new file mode 100644 index 0000000..f4a9bf2 --- /dev/null +++ b/v2_adminpanel/app_before_blueprint.py @@ -0,0 +1,4460 @@ +import os +import sys +import time +import json +import logging +import requests +import re +import random +import base64 +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + +# Import and register blueprints +from routes.auth_routes import auth_bp +from routes.admin_routes import admin_bp + +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) + + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True +) + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app_with_duplicates.py b/v2_adminpanel/app_with_duplicates.py new file mode 100644 index 0000000..5b203fe --- /dev/null +++ b/v2_adminpanel/app_with_duplicates.py @@ -0,0 +1,4462 @@ +import os +import sys +import time +import json +import logging +import requests +import re +import random +import base64 +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + +# Import and register blueprints +from routes.auth_routes import auth_bp +from routes.admin_routes import admin_bp + +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) + + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True +) + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + + +# MOVED TO AUTH BLUEPRINT +# @app.route("/login", methods=["GET", "POST"]) +# def login(): +# # Timing-Attack Schutz - Start Zeit merken +# start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +# MOVED TO AUTH BLUEPRINT +# @app.route("/verify-2fa", methods=["GET", "POST"]) +# def verify_2fa(): +# if not session.get('awaiting_2fa'): +# return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/comment_routes.py b/v2_adminpanel/comment_routes.py new file mode 100644 index 0000000..1c5c44d --- /dev/null +++ b/v2_adminpanel/comment_routes.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Script to comment out routes that have been moved to blueprints +""" + +# Routes that have been moved to auth_routes.py +auth_routes = [ + ("@app.route(\"/login\"", "def login():", 138, 251), # login route + ("@app.route(\"/logout\")", "def logout():", 252, 263), # logout route + ("@app.route(\"/verify-2fa\"", "def verify_2fa():", 264, 342), # verify-2fa route + ("@app.route(\"/profile\")", "def profile():", 343, 352), # profile route + ("@app.route(\"/profile/change-password\"", "def change_password():", 353, 390), # change-password route + ("@app.route(\"/profile/setup-2fa\")", "def setup_2fa():", 391, 410), # setup-2fa route + ("@app.route(\"/profile/enable-2fa\"", "def enable_2fa():", 411, 448), # enable-2fa route + ("@app.route(\"/profile/disable-2fa\"", "def disable_2fa():", 449, 475), # disable-2fa route + ("@app.route(\"/heartbeat\"", "def heartbeat():", 476, 489), # heartbeat route +] + +# Routes that have been moved to admin_routes.py +admin_routes = [ + ("@app.route(\"/\")", "def dashboard():", 647, 870), # dashboard route + ("@app.route(\"/audit\")", "def audit_log():", 2772, 2866), # audit route + ("@app.route(\"/backups\")", "def backups():", 2866, 2901), # backups route + ("@app.route(\"/backup/create\"", "def create_backup_route():", 2901, 2919), # backup/create route + ("@app.route(\"/backup/restore/\"", "def restore_backup_route(backup_id):", 2919, 2938), # backup/restore route + ("@app.route(\"/backup/download/\")", "def download_backup(backup_id):", 2938, 2970), # backup/download route + ("@app.route(\"/backup/delete/\"", "def delete_backup(backup_id):", 2970, 3026), # backup/delete route + ("@app.route(\"/security/blocked-ips\")", "def blocked_ips():", 3026, 3067), # security/blocked-ips route + ("@app.route(\"/security/unblock-ip\"", "def unblock_ip():", 3067, 3093), # security/unblock-ip route + ("@app.route(\"/security/clear-attempts\"", "def clear_attempts():", 3093, 3119), # security/clear-attempts route +] + +print("This script would comment out the following routes:") +print("\nAuth routes:") +for route in auth_routes: + print(f" - {route[0]} (lines {route[2]}-{route[3]})") + +print("\nAdmin routes:") +for route in admin_routes: + print(f" - {route[0]} (lines {route[2]}-{route[3]})") + +print("\nNote: Manual verification and adjustment of line numbers is recommended before running the actual commenting.") \ No newline at end of file diff --git a/v2_adminpanel/remove_duplicate_routes.py b/v2_adminpanel/remove_duplicate_routes.py new file mode 100644 index 0000000..648aab0 --- /dev/null +++ b/v2_adminpanel/remove_duplicate_routes.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Remove duplicate routes that have been moved to blueprints +""" + +import re + +# Read the current app.py +with open('app.py', 'r') as f: + content = f.read() + +# List of function names that have been moved to blueprints +moved_functions = [ + # Auth routes + 'login', + 'logout', + 'verify_2fa', + 'profile', + 'change_password', + 'setup_2fa', + 'enable_2fa', + 'disable_2fa', + 'heartbeat', + # Admin routes + 'dashboard', + 'audit_log', + 'backups', + 'create_backup_route', + 'restore_backup_route', + 'download_backup', + 'delete_backup', + 'blocked_ips', + 'unblock_ip', + 'clear_attempts' +] + +# Create a pattern to match route decorators and their functions +for func_name in moved_functions: + # Pattern to match from @app.route to the end of the function + pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)' + + # Replace with a comment + replacement = f'# Function {func_name} moved to blueprint' + + content = re.sub(pattern, replacement, content, flags=re.DOTALL) + +# Write the modified content +with open('app_no_duplicates.py', 'w') as f: + f.write(content) + +print("Created app_no_duplicates.py with duplicate routes removed") +print("Please review the file before using it") \ No newline at end of file diff --git a/v2_adminpanel/routes/__init__.py b/v2_adminpanel/routes/__init__.py new file mode 100644 index 0000000..4f9ede3 --- /dev/null +++ b/v2_adminpanel/routes/__init__.py @@ -0,0 +1,2 @@ +# Routes module initialization +# This module contains all Flask blueprints organized by functionality \ No newline at end of file diff --git a/v2_adminpanel/routes/admin_routes.py b/v2_adminpanel/routes/admin_routes.py new file mode 100644 index 0000000..cf3528f --- /dev/null +++ b/v2_adminpanel/routes/admin_routes.py @@ -0,0 +1,540 @@ +import os +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.backup import create_backup, restore_backup +from utils.network import get_client_ip +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from utils.export import create_excel_export, prepare_audit_export_data + +# Create Blueprint +admin_bp = Blueprint('admin', __name__) + + +@admin_bp.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Statistiken + # Anzahl aktiver Lizenzen + cur.execute("SELECT COUNT(*) FROM licenses WHERE active = true") + active_licenses = cur.fetchone()[0] + + # Anzahl Kunden + cur.execute("SELECT COUNT(*) FROM customers") + total_customers = cur.fetchone()[0] + + # Anzahl aktiver Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE active = true") + active_sessions = cur.fetchone()[0] + + # Top 10 Lizenzen nach Nutzung (letzte 30 Tage) + cur.execute(""" + SELECT + l.license_key, + c.name as customer_name, + COUNT(DISTINCT s.id) as session_count, + COUNT(DISTINCT s.username) as unique_users, + MAX(s.last_activity) as last_activity + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + LEFT JOIN sessions s ON l.license_key = s.license_key + AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days' + GROUP BY l.license_key, c.name + ORDER BY session_count DESC + LIMIT 10 + """) + top_licenses = cur.fetchall() + + # Letzte 10 Aktivitäten aus dem Audit Log + cur.execute(""" + SELECT + id, + timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, + username, + action, + entity_type, + entity_id, + additional_info + FROM audit_log + ORDER BY timestamp DESC + LIMIT 10 + """) + recent_activities = cur.fetchall() + + # Lizenztyp-Verteilung + cur.execute(""" + SELECT + CASE + WHEN is_test_license THEN 'Test' + ELSE 'Full' + END as license_type, + COUNT(*) as count + FROM licenses + GROUP BY is_test_license + """) + license_distribution = cur.fetchall() + + # Sessions nach Stunden (letzte 24h) + cur.execute(""" + WITH hours AS ( + SELECT generate_series( + CURRENT_TIMESTAMP - INTERVAL '23 hours', + CURRENT_TIMESTAMP, + INTERVAL '1 hour' + ) AS hour + ) + SELECT + TO_CHAR(hours.hour AT TIME ZONE 'Europe/Berlin', 'HH24:00') as hour_label, + COUNT(DISTINCT s.id) as session_count + FROM hours + LEFT JOIN sessions s ON + s.login_time >= hours.hour AND + s.login_time < hours.hour + INTERVAL '1 hour' + GROUP BY hours.hour + ORDER BY hours.hour + """) + hourly_sessions = cur.fetchall() + + # System-Status + cur.execute("SELECT pg_database_size(current_database())") + db_size = cur.fetchone()[0] + + # Letzte Backup-Info + cur.execute(""" + SELECT filename, created_at, filesize, status + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Resource Statistiken + cur.execute(""" + SELECT + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'in_use') as in_use, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resources + """) + resource_stats = cur.fetchone() + + return render_template('dashboard.html', + active_licenses=active_licenses, + total_customers=total_customers, + active_sessions=active_sessions, + top_licenses=top_licenses, + recent_activities=recent_activities, + license_distribution=license_distribution, + hourly_sessions=hourly_sessions, + db_size=db_size, + last_backup=last_backup, + resource_stats=resource_stats, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/audit") +@login_required +def audit_log(): + page = request.args.get('page', 1, type=int) + per_page = 50 + search = request.args.get('search', '') + action_filter = request.args.get('action', '') + entity_filter = request.args.get('entity', '') + + conn = get_connection() + cur = conn.cursor() + + try: + # Base query + query = """ + SELECT + id, + timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, + username, + action, + entity_type, + entity_id, + old_values::text, + new_values::text, + ip_address, + user_agent, + additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + # Suchfilter + if search: + query += """ AND ( + username ILIKE %s OR + action ILIKE %s OR + entity_type ILIKE %s OR + additional_info ILIKE %s OR + ip_address ILIKE %s + )""" + search_param = f"%{search}%" + params.extend([search_param] * 5) + + # Action Filter + if action_filter: + query += " AND action = %s" + params.append(action_filter) + + # Entity Filter + if entity_filter: + query += " AND entity_type = %s" + params.append(entity_filter) + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as filtered" + cur.execute(count_query, params) + total_count = cur.fetchone()[0] + + # Add pagination + query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" + params.extend([per_page, (page - 1) * per_page]) + + cur.execute(query, params) + logs = cur.fetchall() + + # Get unique actions and entities for filters + cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action") + actions = [row[0] for row in cur.fetchall()] + + cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type") + entities = [row[0] for row in cur.fetchall()] + + # Pagination info + total_pages = (total_count + per_page - 1) // per_page + + # Convert to dictionaries for easier template access + audit_logs = [] + for log in logs: + audit_logs.append({ + 'id': log[0], + 'timestamp': log[1], + 'username': log[2], + 'action': log[3], + 'entity_type': log[4], + 'entity_id': log[5], + 'old_values': log[6], + 'new_values': log[7], + 'ip_address': log[8], + 'user_agent': log[9], + 'additional_info': log[10] + }) + + return render_template('audit_log.html', + logs=audit_logs, + page=page, + total_pages=total_pages, + total_count=total_count, + search=search, + action_filter=action_filter, + entity_filter=entity_filter, + actions=actions, + entities=entities, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backups") +@login_required +def backups(): + conn = get_connection() + cur = conn.cursor() + + try: + # Hole alle Backups + cur.execute(""" + SELECT + id, + filename, + created_at AT TIME ZONE 'Europe/Berlin' as created_at, + filesize, + backup_type, + status, + created_by, + duration_seconds, + tables_count, + records_count, + error_message, + is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + # Prüfe ob Dateien noch existieren + backups_with_status = [] + for backup in backups: + backup_dict = { + 'id': backup[0], + 'filename': backup[1], + 'created_at': backup[2], + 'filesize': backup[3], + 'backup_type': backup[4], + 'status': backup[5], + 'created_by': backup[6], + 'duration_seconds': backup[7], + 'tables_count': backup[8], + 'records_count': backup[9], + 'error_message': backup[10], + 'is_encrypted': backup[11], + 'file_exists': False + } + + # Prüfe ob Datei existiert + if backup[1]: # filename + filepath = config.BACKUP_DIR / backup[1] + backup_dict['file_exists'] = filepath.exists() + + backups_with_status.append(backup_dict) + + return render_template('backups.html', + backups=backups_with_status, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Manuelles Backup erstellen""" + success, result = create_backup(backup_type="manual", created_by=session.get('username')) + + if success: + flash(f'Backup erfolgreich erstellt: {result}', 'success') + else: + flash(f'Backup fehlgeschlagen: {result}', 'error') + + return redirect(url_for('admin.backups')) + + +@admin_bp.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Backup wiederherstellen""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + flash(message, 'success') + else: + flash(f'Wiederherstellung fehlgeschlagen: {message}', 'error') + + return redirect(url_for('admin.backups')) + + +@admin_bp.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Backup herunterladen""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Backup-Info + cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) + result = cur.fetchone() + + if not result: + flash('Backup nicht gefunden', 'error') + return redirect(url_for('admin.backups')) + + filename, filepath = result + filepath = Path(filepath) + + if not filepath.exists(): + flash('Backup-Datei nicht gefunden', 'error') + return redirect(url_for('admin.backups')) + + # Audit-Log + log_audit('BACKUP_DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Backup löschen""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Backup-Info + cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) + result = cur.fetchone() + + if not result: + return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404 + + filename, filepath = result + filepath = Path(filepath) + + # Lösche Datei wenn vorhanden + if filepath.exists(): + try: + filepath.unlink() + except Exception as e: + return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500 + + # Lösche Datenbank-Eintrag + cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,)) + conn.commit() + + # Audit-Log + log_audit('BACKUP_DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'}) + + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'message': str(e)}), 500 + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt gesperrte IP-Adressen""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + SELECT + ip_address, + attempt_count, + last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, + blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + ORDER BY blocked_until DESC + """) + blocked = cur.fetchall() + + # Alle Login-Versuche (auch nicht gesperrte) + cur.execute(""" + SELECT + ip_address, + attempt_count, + last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, + blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + ORDER BY last_attempt DESC + LIMIT 100 + """) + all_attempts = cur.fetchall() + + return render_template('blocked_ips.html', + blocked_ips=blocked, + all_attempts=all_attempts, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if not ip_address: + flash('Keine IP-Adresse angegeben', 'error') + return redirect(url_for('admin.blocked_ips')) + + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + if cur.rowcount > 0: + conn.commit() + flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success') + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP-Adresse entsperrt: {ip_address}") + else: + flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning') + + except Exception as e: + conn.rollback() + flash(f'Fehler beim Entsperren: {str(e)}', 'error') + + finally: + cur.close() + conn.close() + + return redirect(url_for('admin.blocked_ips')) + + +@admin_bp.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute("DELETE FROM login_attempts") + count = cur.rowcount + conn.commit() + + flash(f'{count} Login-Versuche wurden gelöscht', 'success') + log_audit('CLEAR_LOGIN_ATTEMPTS', 'security', + additional_info=f"{count} Login-Versuche gelöscht") + + except Exception as e: + conn.rollback() + flash(f'Fehler beim Löschen: {str(e)}', 'error') + + finally: + cur.close() + conn.close() + + return redirect(url_for('admin.blocked_ips')) \ No newline at end of file diff --git a/v2_adminpanel/routes/auth_routes.py b/v2_adminpanel/routes/auth_routes.py new file mode 100644 index 0000000..69a5c7d --- /dev/null +++ b/v2_adminpanel/routes/auth_routes.py @@ -0,0 +1,377 @@ +import time +import json +from datetime import datetime +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify + +import config +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from models import get_user_by_username +from db import get_db_connection, get_db_cursor +from utils.recaptcha import verify_recaptcha + +# Create Blueprint +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('auth.verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('admin.dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + +@auth_bp.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('auth.login')) + + +@auth_bp.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('auth.login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('auth.login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('auth.login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('admin.dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('admin.dashboard')) + + # Failed verification + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + + +@auth_bp.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('admin.dashboard')) + return render_template('profile.html', user=user) + + +@auth_bp.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('auth.profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('auth.profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('auth.profile')) + + # Update password + new_hash = hash_password(new_password) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('auth.profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + + +@auth_bp.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + backup_codes_hashed = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA for user + user = get_user_by_username(session['username']) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = true, backup_codes = %s + WHERE id = %s + """, (totp_secret, json.dumps(backup_codes_hashed), user['id'])) + + # Clear temp secret + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', entity_id=user['id'], + additional_info="2FA successfully enabled") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + + +@auth_bp.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password. 2FA was not disabled.', 'error') + return redirect(url_for('auth.profile')) + + # Disable 2FA + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL + WHERE id = %s + """, (user['id'],)) + + log_audit('2FA_DISABLED', 'user', entity_id=user['id'], + additional_info="2FA disabled by user") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) \ No newline at end of file diff --git a/v2_adminpanel/test_blueprints.py b/v2_adminpanel/test_blueprints.py new file mode 100644 index 0000000..30710b4 --- /dev/null +++ b/v2_adminpanel/test_blueprints.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Test if blueprints can be imported successfully""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from routes.auth_routes import auth_bp + print("✓ auth_routes blueprint imported successfully") + print(f" Routes: {[str(r) for r in auth_bp.url_values_defaults]}") +except Exception as e: + print(f"✗ Error importing auth_routes: {e}") + +try: + from routes.admin_routes import admin_bp + print("✓ admin_routes blueprint imported successfully") +except Exception as e: + print(f"✗ Error importing admin_routes: {e}") + +print("\nBlueprints are ready to use!") \ No newline at end of file diff --git a/v2_adminpanel/utils/recaptcha.py b/v2_adminpanel/utils/recaptcha.py new file mode 100644 index 0000000..344b545 --- /dev/null +++ b/v2_adminpanel/utils/recaptcha.py @@ -0,0 +1,39 @@ +import logging +import requests +import config + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False \ No newline at end of file